~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

The appview keeps secrets sealed to a runner

Closed chadtmiller.com asked to merge into main from chadtmiller.com/knotless:serve/sealed-secrets
.changeset/sealed-secrets-appview.mdnew+7−0
@@ -0,0 +1,7 @@
---
default: minor
---
# The appview keeps secrets sealed to a runner
knotless-serve answers `dev.knotless.putSecret`, `deleteSecret` and `listSecrets`: an owner leaves a repository's runners secrets, each sealed to one runner's key, and the appview holds only ciphertext. knotless's login asks for these methods, so a session from before asks again at its next login.
crates/knotless/src/invites.rs+1−1
@@ -23,7 +23,7 @@ pub const OUTPUT: &str = "dev.knotless.putRunOutput";
pub const WRITES: &str = "dev.knotless.awaitSpaceWrites";
/// The appview, and the DID its service tokens are for.
async fn appview(ctx: &Ctx) -> Result<(Xrpc, String)> {
pub(crate) async fn appview(ctx: &Ctx) -> Result<(Xrpc, String)> {
let url = ctx
.env
.appview
crates/knotless/src/lib.rs+1−0
@@ -54,6 +54,7 @@ pub mod remote;
pub mod revert;
pub mod reviews;
pub mod sealed;
pub mod secrets;
pub mod session;
pub mod social;
pub mod space;
crates/knotless/src/oauth.rs+3−0
@@ -88,6 +88,9 @@ pub static SCOPE: LazyLock<String> = LazyLock::new(|| {
crate::invites::LIST,
crate::invites::OUTPUT,
crate::invites::WRITES,
crate::secrets::PUT,
crate::secrets::DELETE,
crate::secrets::LIST,
crate::notifications::LIST,
crate::notifications::SEEN,
crate::notifications::GET_PREFERENCES,
crates/knotless/src/secrets.rsnew+141−0
@@ -0,0 +1,141 @@
//! Secrets an owner gives a repository's runners, each sealed to a runner's
//! key ([`crate::sealed`]) and left at the appview, which holds only
//! ciphertext. The owner puts, lists and deletes them; a runner lists those
//! sealed to it, to open. Each signs for itself with a service token.
use crate::auth;
use crate::env::Ctx;
use crate::invites::appview;
use crate::session::Session;
use crate::xrpc::Auth;
use anyhow::Result;
use base64::Engine;
use base64::engine::general_purpose::STANDARD_NO_PAD;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
pub const PUT: &str = "dev.knotless.putSecret";
pub const DELETE: &str = "dev.knotless.deleteSecret";
pub const LIST: &str = "dev.knotless.listSecrets";
/// The longest a secret's value is, in bytes, before it is sealed.
pub const VALUE_MAX: usize = 64 * 1024;
/// The most refs one is limited to.
pub const REFS_MAX: usize = 16;
/// A secret left with one runner, as the appview lists it.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Secret {
pub name: String,
pub runner: String,
/// The refs whose pushes it reaches, none for every push.
#[serde(default, with = "patterns")]
pub refs: Vec<String>,
/// The runner's key it is sealed to, a `did:key`.
pub key: String,
/// The sealed value, listed to the runner alone.
#[serde(default, with = "bytes", skip_serializing_if = "Option::is_none")]
pub sealed: Option<Vec<u8>>,
pub updated_at: String,
}
/// Refs as the lexicons hold them: `[{"pattern": "refs/heads/main"}]`.
pub mod patterns {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Serialize, Deserialize)]
struct Pattern {
pattern: String,
}
pub fn serialize<S: Serializer>(refs: &[String], s: S) -> Result<S::Ok, S::Error> {
let all = refs.iter().map(|p| Pattern { pattern: p.clone() });
s.collect_seq(all)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
let all = Vec::<Pattern>::deserialize(d)?;
Ok(all.into_iter().map(|p| p.pattern).collect())
}
}
/// Bytes as atproto's JSON writes them: `{"$bytes": base64}`.
pub mod bytes {
use super::*;
use serde::{Deserializer, Serializer};
#[derive(Serialize, Deserialize)]
struct Lex {
#[serde(rename = "$bytes")]
bytes: String,
}
pub fn serialize<S: Serializer>(b: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
let b = b.as_deref().unwrap_or_default();
Lex {
bytes: STANDARD_NO_PAD.encode(b),
}
.serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
let lex = Option::<Lex>::deserialize(d)?;
let decoded = lex.map(|l| STANDARD_NO_PAD.decode(l.bytes.trim_end_matches('=')));
decoded.transpose().map_err(serde::de::Error::custom)
}
}
/// Calls the procedure `nsid` as `me`.
async fn call(ctx: &Ctx, me: &Session, nsid: &str, body: &Value) -> Result<Value> {
let (xrpc, did) = appview(ctx).await?;
let token = auth::service_token(ctx, me, &did, nsid).await?;
let auth = Auth::Bearer(token);
xrpc.procedure(nsid, Some(body), Some(&auth)).await
}
/// Leaves `secret`, sealed, for `me`'s repository `repo`, by its key.
pub async fn put(ctx: &Ctx, me: &Session, repo: &str, secret: &Secret) -> Result<()> {
let mut body = serde_json::to_value(secret)?;
body["repo"] = repo.into();
let fields = ["repo", "runner", "name", "refs", "key", "sealed"];
body.as_object_mut()
.expect("a secret is an object")
.retain(|k, _| fields.contains(&k.as_str()));
call(ctx, me, PUT, &body).await.map(drop)
}
/// Deletes those of `me`'s repository `repo` of `name`, sealed to
/// `runner`, or both; with neither, all of them. Says how many it deleted.
pub async fn delete(
ctx: &Ctx,
me: &Session,
repo: &str,
name: Option<&str>,
runner: Option<&str>,
) -> Result<u64> {
let mut body = json!({ "repo": repo });
let only = [("name", name), ("runner", runner)];
for (field, value) in only.into_iter().filter_map(|(f, v)| Some((f, v?))) {
body[field] = value.into();
}
let done = call(ctx, me, DELETE, &body).await?;
Ok(done["deleted"].as_u64().unwrap_or_default())
}
/// Those left for `owner`'s repository `repo` that `me` may see: every
/// one, without its value, to its owner; those sealed to it, to a runner.
pub async fn list(ctx: &Ctx, me: &Session, owner: &str, repo: &str) -> Result<Vec<Secret>> {
#[derive(Deserialize)]
struct Page {
secrets: Vec<Secret>,
}
let (xrpc, did) = appview(ctx).await?;
let token = auth::service_token(ctx, me, &did, LIST).await?;
let params = [("owner", owner), ("repo", repo)];
let page: Page = xrpc
.query(LIST, &params, Some(&Auth::Bearer(token)))
.await?;
Ok(page.secrets)
}
crates/knotless-serve/src/index.rs+67−0
@@ -14,6 +14,7 @@ use knotless::forge::Entry;
use knotless::records::{
FailedCheck, OrgMemberRecord, OrgRecord, ReleaseRecord, RepoRecord, RepoRef, nsid,
};
use knotless::secrets::Secret;
use knotless::usage::{Day, Network};
use rusqlite::functions::{Context, FunctionFlags};
use rusqlite::{Connection, OpenFlags, OptionalExtension, params};
@@ -273,6 +274,7 @@ const PURGE: &[&str] = &[
"DELETE FROM profile_record WHERE did = ?1",
"DELETE FROM handle WHERE did = ?1",
"DELETE FROM invite WHERE did = ?1 OR runner = ?1",
"DELETE FROM secret WHERE owner = ?1 OR runner = ?1",
"DELETE FROM inactive WHERE did = ?1",
"DELETE FROM space_record WHERE did = ?1 OR substr(space, 1, length(?2)) = ?2",
"DELETE FROM space_writer WHERE did = ?1 OR substr(space, 1, length(?2)) = ?2",
@@ -424,6 +426,11 @@ impl Index {
CREATE TABLE IF NOT EXISTS invite (
id INTEGER PRIMARY KEY AUTOINCREMENT, runner TEXT NOT NULL, did TEXT NOT NULL,
name TEXT NOT NULL, at TEXT NOT NULL, UNIQUE (runner, did, name));
CREATE TABLE IF NOT EXISTS secret (
owner TEXT NOT NULL, repo TEXT NOT NULL, runner TEXT NOT NULL,
name TEXT NOT NULL, refs TEXT NOT NULL, key TEXT NOT NULL,
sealed BLOB NOT NULL, at TEXT NOT NULL,
PRIMARY KEY (owner, repo, runner, name));
CREATE TABLE IF NOT EXISTS inactive (did TEXT PRIMARY KEY, status TEXT NOT NULL);
CREATE TEMP VIEW IF NOT EXISTS down AS
SELECT subject FROM takedown
@@ -2150,6 +2157,54 @@ impl Index {
.collect()
}
/// Keeps `secret`, sealed, for `owner`'s repository `repo`, in place
/// of any of its name sealed to the same runner.
pub fn put_secret(&self, owner: &str, repo: &str, secret: &Secret) -> rusqlite::Result<()> {
let db = self.db.lock().unwrap();
let refs = serde_json::to_string(&secret.refs).expect("refs are strings");
db.execute(
"INSERT OR REPLACE INTO secret VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
owner,
repo,
secret.runner,
secret.name,
refs,
secret.key,
secret.sealed,
secret.updated_at
],
)?;
Ok(())
}
/// Deletes `owner`'s repository `repo`'s secrets of `name`, sealed to
/// `runner`, or both; with neither, all of them. Says how many.
pub fn delete_secrets(
&self,
owner: &str,
repo: &str,
name: Option<&str>,
runner: Option<&str>,
) -> rusqlite::Result<usize> {
let db = self.db.lock().unwrap();
db.execute(
"DELETE FROM secret WHERE owner = ?1 AND repo = ?2
AND (?3 IS NULL OR name = ?3) AND (?4 IS NULL OR runner = ?4)",
params![owner, repo, name, runner],
)
}
/// `owner`'s repository `repo`'s secrets, by name, then runner.
pub fn secrets(&self, owner: &str, repo: &str) -> rusqlite::Result<Vec<Secret>> {
let db = self.db.lock().unwrap();
let mut stmt = db.prepare_cached(
"SELECT name, runner, refs, key, sealed, at FROM secret
WHERE owner = ?1 AND repo = ?2 ORDER BY name, runner",
)?;
stmt.query_map([owner, repo], secret_row)?.collect()
}
/// The name `did`'s repository `key` has now.
pub fn name(&self, did: &str, key: &str) -> rusqlite::Result<Option<String>> {
let db = self.db.lock().unwrap();
@@ -2651,6 +2706,18 @@ fn org_rows(
}
}
fn secret_row(row: &rusqlite::Row) -> rusqlite::Result<Secret> {
let refs: String = row.get(2)?;
Ok(Secret {
name: row.get(0)?,
runner: row.get(1)?,
refs: serde_json::from_str(&refs).unwrap_or_default(),
key: row.get(3)?,
sealed: row.get(4)?,
updated_at: row.get(5)?,
})
}
fn invitee(row: &rusqlite::Row) -> rusqlite::Result<Invitee> {
Ok(Invitee {
did: row.get(0)?,
crates/knotless-serve/src/lib.rs+4−0
@@ -34,6 +34,7 @@ pub mod oci;
mod orgs;
pub mod private;
pub mod releases;
mod secrets;
mod smart;
pub mod spaces;
pub mod status;
@@ -877,6 +878,9 @@ pub fn router(app: Arc<App>) -> Router {
post(spaces::deleted),
)
.route("/xrpc/dev.knotless.listRunnerInvites", get(invites::list))
.route("/xrpc/dev.knotless.putSecret", post(secrets::put))
.route("/xrpc/dev.knotless.deleteSecret", post(secrets::delete))
.route("/xrpc/dev.knotless.listSecrets", get(secrets::list))
.route(
"/xrpc/dev.knotless.putRunOutput",
post(live::put_run_output).layer(axum::extract::DefaultBodyLimit::max(live::BODY)),
crates/knotless-serve/src/secrets.rsnew+126−0
@@ -0,0 +1,126 @@
//! Secrets owners leave for their repositories' runners
//! ([`knotless::secrets`]), each sealed to a runner's key, so this server
//! holds only ciphertext. An owner puts, lists and deletes its own; a
//! runner lists those sealed to it. Each signs for itself with a service
//! token, and a repository is named by its owner and key, never its name.
use crate::gateway::caller;
use crate::moderation::{bad, index};
use crate::{App, AppError};
use axum::Json;
use axum::extract::{Query, State};
use axum::http::HeaderMap;
use knotless::records::RerunVar;
use knotless::secrets::{DELETE, LIST, PUT, REFS_MAX, Secret, VALUE_MAX};
use serde::Deserialize;
use serde_json::{Value, json};
use std::sync::Arc;
/// The most a sealed value takes: the encapsulated key, the value, and the
/// AEAD's tag.
const SEALED_MAX: usize = 65 + VALUE_MAX + 16;
/// Whether `repo` could be a repository's key: a TID, or a key as long.
fn valid_repo(repo: &str) -> bool {
(1..=32).contains(&repo.len()) && repo.bytes().all(|b| b.is_ascii_alphanumeric())
}
fn internal(err: rusqlite::Error) -> AppError {
AppError::internal(err.into())
}
#[derive(Deserialize)]
pub struct Put {
repo: String,
runner: String,
name: String,
#[serde(default, with = "knotless::secrets::patterns")]
refs: Vec<String>,
key: String,
#[serde(default, with = "knotless::secrets::bytes")]
sealed: Option<Vec<u8>>,
}
pub async fn put(
State(app): State<Arc<App>>,
headers: HeaderMap,
Json(body): Json<Put>,
) -> Result<Json<Value>, AppError> {
let index = index(&app)?;
let owner = caller(&app, &headers, PUT).await?;
if !valid_repo(&body.repo) {
return Err(bad("a repository is named by its key"));
}
if !body.runner.starts_with("did:") || !body.key.starts_with("did:key:") {
return Err(bad("a runner and its key are named by their DIDs"));
}
if !RerunVar::valid_name(&body.name) {
return Err(bad(
"a secret's name is a shell variable's, not one starting KNOTLESS_RUN",
));
}
let refs_ok = body.refs.iter().all(|r| (1..=256).contains(&r.len()));
if body.refs.len() > REFS_MAX || !refs_ok {
return Err(bad(format!(
"at most {REFS_MAX} refs, each a ref or a prefix"
)));
}
let sealed = body.sealed.unwrap_or_default();
if sealed.is_empty() || sealed.len() > SEALED_MAX {
return Err(bad(format!("a sealed value is at most {SEALED_MAX} bytes")));
}
let secret = Secret {
name: body.name,
runner: body.runner,
refs: body.refs,
key: body.key,
sealed: Some(sealed),
updated_at: knotless::records::now(),
};
index
.put_secret(&owner, &body.repo, &secret)
.map_err(internal)?;
Ok(Json(json!({})))
}
#[derive(Deserialize)]
pub struct Delete {
repo: String,
name: Option<String>,
runner: Option<String>,
}
pub async fn delete(
State(app): State<Arc<App>>,
headers: HeaderMap,
Json(body): Json<Delete>,
) -> Result<Json<Value>, AppError> {
let index = index(&app)?;
let owner = caller(&app, &headers, DELETE).await?;
let (name, runner) = (body.name.as_deref(), body.runner.as_deref());
let deleted = index.delete_secrets(&owner, &body.repo, name, runner);
Ok(Json(json!({ "deleted": deleted.map_err(internal)? })))
}
#[derive(Deserialize)]
pub struct ListParams {
owner: String,
repo: String,
}
pub async fn list(
State(app): State<Arc<App>>,
headers: HeaderMap,
Query(p): Query<ListParams>,
) -> Result<Json<Value>, AppError> {
let index = index(&app)?;
let me = caller(&app, &headers, LIST).await?;
let mut secrets = index.secrets(&p.owner, &p.repo).map_err(internal)?;
// Its owner sees every one, and no value; a runner, only its own.
if me == p.owner {
secrets.iter_mut().for_each(|s| s.sealed = None);
} else {
secrets.retain(|s| s.runner == me);
}
Ok(Json(json!({ "secrets": secrets })))
}
crates/knotless-serve/tests/forge.rs+129−0
@@ -2340,6 +2340,135 @@ fn invites_reach_their_runner() {
assert_eq!(get("?limit=1", &inviting), 401);
}
#[test]
fn secrets_reach_only_their_runner() {
use knotless::secrets::{self, Secret};
let av = appview_following(false, "");
let owner = person(account(), &av);
let runner = person(account(), &av);
let stranger = person(account(), &av);
let (o, r, x) = (ctx(&owner), ctx(&runner), ctx(&stranger));
let me = |c: &knotless::env::Ctx| av.rt.block_on(knotless::session::me(c)).unwrap();
let (mine, its, theirs) = (me(&o), me(&r), me(&x));
let (od, rd) = (owner.account.did.clone(), runner.account.did.clone());
let secret = |name: &str, runner: &str, refs: &[&str], sealed: &[u8]| Secret {
name: name.into(),
runner: runner.into(),
refs: refs.iter().map(|r| r.to_string()).collect(),
key: "did:key:zDnaeTest".into(),
sealed: Some(sealed.to_vec()),
updated_at: String::new(),
};
let put = |s: Secret| {
av.rt
.block_on(secrets::put(&o, &mine, "3kabc", &s))
.unwrap()
};
put(secret("TOKEN", &rd, &[], b"old"));
// Put again under the same name and runner, it is replaced.
put(secret("TOKEN", &rd, &["refs/heads/main"], b"sealed"));
put(secret(
"TOKEN",
"did:web:elsewhere.example",
&[],
b"elsewhere",
));
put(secret("DEPLOY", &rd, &[], b"deploy"));
let list = |c, who, owner: &str| {
let listed = av.rt.block_on(secrets::list(c, who, owner, "3kabc"));
listed.unwrap()
};
// Its owner sees every one, by name, and never a value.
let all = list(&o, &mine, &od);
let names: Vec<_> = all
.iter()
.map(|s| (s.name.as_str(), s.runner.as_str()))
.collect();
assert_eq!(
names,
[
("DEPLOY", rd.as_str()),
("TOKEN", rd.as_str()),
("TOKEN", "did:web:elsewhere.example")
]
);
assert!(all.iter().all(|s| s.sealed.is_none()));
// A DID of the PLC sorts before one of the web.
assert_eq!(all[1].refs, ["refs/heads/main"]);
assert!(all[1].updated_at.ends_with('Z'), "{all:?}");
// The runner sees those sealed to it, with their values; anyone else,
// none.
let own = list(&r, &its, &od);
let sealed: Vec<_> = own.iter().map(|s| s.sealed.clone().unwrap()).collect();
assert_eq!(sealed, [b"deploy".to_vec(), b"sealed".to_vec()]);
assert_eq!(own[1].key, "did:key:zDnaeTest");
assert!(list(&x, &theirs, &od).is_empty());
// Deleted by name, for every runner; by runner; then the rest.
let delete = |name: Option<&str>, runner: Option<&str>| {
let done = secrets::delete(&o, &mine, "3kabc", name, runner);
av.rt.block_on(done).unwrap()
};
assert_eq!(delete(Some("TOKEN"), Some("did:web:elsewhere.example")), 1);
assert_eq!(delete(Some("TOKEN"), None), 1);
put(secret("TOKEN", &rd, &[], b"again"));
assert_eq!(delete(None, Some(&rd)), 2);
put(secret("TOKEN", &rd, &[], b"again"));
assert_eq!(delete(None, None), 1);
assert!(list(&o, &mine, &od).is_empty());
// A stranger's delete deletes only its own, which it has none of.
let theirs_gone = secrets::delete(&x, &theirs, "3kabc", None, None);
assert_eq!(av.rt.block_on(theirs_gone).unwrap(), 0);
// What a put must be, each refused.
let client = reqwest::blocking::Client::new();
let token = |lxm: &str| {
let token = knotless::auth::service_token(&o, &mine, &av.did, lxm);
format!("Bearer {}", av.rt.block_on(token).unwrap())
};
let putting = token(secrets::PUT);
let post = |body: Value| {
let url = format!("{}/xrpc/{}", av.base, secrets::PUT);
let req = client.post(url).header("authorization", &putting);
req.json(&body).send().unwrap().status().as_u16()
};
let good = serde_json::json!({
"repo": "3kabc", "runner": rd, "name": "TOKEN",
"key": "did:key:zDnaeTest", "sealed": {"$bytes": "c2VhbGVk"},
});
let with = |field: &str, value: Value| {
let mut body = good.clone();
body[field] = value;
body
};
let many: Vec<Value> = (0..17)
.map(|_| serde_json::json!({"pattern": "refs/heads/x"}))
.collect();
let big = "A".repeat(90_000);
for bad in [
with("repo", "a/b".into()),
with("runner", "someone".into()),
with("key", "did:plc:notakey".into()),
with("name", "KNOTLESS_RUN_X".into()),
with("refs", many.into()),
with("refs", serde_json::json!([{"pattern": ""}])),
with("sealed", serde_json::json!({"$bytes": ""})),
with("sealed", serde_json::json!({"$bytes": big})),
] {
assert_eq!(post(bad.clone()), 400, "{bad}");
}
assert_eq!(post(good), 200);
// Each method takes a token for itself.
let url = format!("{}/xrpc/{}?owner={od}&repo=3kabc", av.base, secrets::LIST);
let listed = client
.get(url)
.header("authorization", &putting)
.send()
.unwrap();
assert_eq!(listed.status().as_u16(), 401);
}
#[test]
fn accounts_away_and_gone() {
let av = appview();
docs/design/access.md+1−1
@@ -55,7 +55,7 @@ knotless asks only for what it does, rather than for the whole account as
| --- | --- |
| `repo:` | each `dev.knotless.*` record it writes |
| `blob:` | packs, releases' files, pictures in profiles, pulls, issues and comments (PNG, JPEG, WebP or GIF), and a runner's checks' logs (plain text) and outputs (PNG, JPEG, WebP or GIF) |
| `rpc:` | the service tokens it gets to call gateways (`pushRefs`, `createCommit`, `putDelegation`, `getDelegation`, and for organizations `putOrgMember`, `removeOrgMember`, `joinOrg`, `leaveOrg` and `createOrgRepo`), to ask an appview who is in an organization, as one inside it (`getOrg`, `getProfile`), and moderation services (`createReport`, and for an operator `updateSubjectStatus`, `getSubjectStatus`, `listReports` and `getUsage`), to read and mark the account's notifications and choose which it hears of (`listNotifications`, `updateSeen`, `getNotificationPreferences`, `putNotificationPreferences`), to have an appview forget an account whose knotless data it deleted (`deleteAccountData`), to invite a private repository's runners or read a runner's invites (`inviteRunner`, `listRunnerInvites`), and for a runner to send its runs' output and hear of writes in its private repositories' spaces (`putRunOutput`, `awaitSpaceWrites`), at any audience, since anyone can run a gateway |
| `rpc:` | the service tokens it gets to call gateways (`pushRefs`, `createCommit`, `putDelegation`, `getDelegation`, and for organizations `putOrgMember`, `removeOrgMember`, `joinOrg`, `leaveOrg` and `createOrgRepo`), to ask an appview who is in an organization, as one inside it (`getOrg`, `getProfile`), and moderation services (`createReport`, and for an operator `updateSubjectStatus`, `getSubjectStatus`, `listReports` and `getUsage`), to read and mark the account's notifications and choose which it hears of (`listNotifications`, `updateSeen`, `getNotificationPreferences`, `putNotificationPreferences`), to have an appview forget an account whose knotless data it deleted (`deleteAccountData`), to invite a private repository's runners or read a runner's invites (`inviteRunner`, `listRunnerInvites`), to give a repository's runners sealed secrets, or read them as a runner (`putSecret`, `deleteSecret`, `listSecrets`), and for a runner to send its runs' output and hear of writes in its private repositories' spaces (`putRunOutput`, `awaitSpaceWrites`), at any audience, since anyone can run a gateway |
| `space` | private repositories |
The scope is built from the same constants the records and calls use, so a
docs/design/security.md+9−0
docs/reference/api.md+15−0
lexicons/dev/knotless/deleteSecret.jsonnew+32−0
lexicons/dev/knotless/listSecrets.jsonnew+49−0
lexicons/dev/knotless/putSecret.jsonnew+29−0