chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The appview keeps secrets sealed to a runner
.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, ¶ms, 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 | ||