don't
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(knot): repository crud

Signed-off-by: tjh <did:plc:65gha4t3avpfpzmvpbwovss7>

tjh.dev ced1083f 2911f844

verified
+1040 -117
+22
.sqlx/query-172ef5110ff75386a02a51265130c0e6ca58abe13a5d25c7787fa8f5c3d2c330.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT member_did FROM knot_member WHERE instance_name = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "member_did", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "172ef5110ff75386a02a51265130c0e6ca58abe13a5d25c7787fa8f5c3d2c330" 22 + }
+15
.sqlx/query-17a82709db35100b19d26fb327c0f21f0fab6809185d0a3cd817092e34de75d2.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM repository WHERE did = $1 AND rkey = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "17a82709db35100b19d26fb327c0f21f0fab6809185d0a3cd817092e34de75d2" 15 + }
+23
.sqlx/query-32f4262d9e9611fd25095a1f369c17a3645627fecd4c8e77bcf5a9e071d55988.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT rkey FROM repository WHERE did = $1 AND (rkey = $2 OR name = $2)", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "rkey", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + "Text" 16 + ] 17 + }, 18 + "nullable": [ 19 + false 20 + ] 21 + }, 22 + "hash": "32f4262d9e9611fd25095a1f369c17a3645627fecd4c8e77bcf5a9e071d55988" 23 + }
-53
.sqlx/query-41661dd412f5ae03fdf1e754a6011dd755ffeed6573029a726accb441329d1ad.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "INSERT INTO repository\n (did, rkey, cid, name, knot, spindle, description, website, topics, source, labels, created_at, xrpc_create_at, jetstream_at)\nVALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\nON CONFLICT\n ON CONSTRAINT repository_pkey\n DO UPDATE\n SET jetstream_at = $14 WHERE repository.jetstream_at IS NULL\n RETURNING\n name, xrpc_create_at, OLD.jetstream_at as old_jetstream_at, NEW.jetstream_at AS new_jetstream_at\n", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "name", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "xrpc_create_at", 14 - "type_info": "Timestamptz" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "old_jetstream_at", 19 - "type_info": "Timestamptz" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "new_jetstream_at", 24 - "type_info": "Timestamptz" 25 - } 26 - ], 27 - "parameters": { 28 - "Left": [ 29 - "Text", 30 - "Text", 31 - "Text", 32 - "Text", 33 - "Text", 34 - "Text", 35 - "Text", 36 - "Text", 37 - "TextArray", 38 - "Text", 39 - "TextArray", 40 - "Timestamptz", 41 - "Timestamptz", 42 - "Timestamptz" 43 - ] 44 - }, 45 - "nullable": [ 46 - false, 47 - true, 48 - true, 49 - true 50 - ] 51 - }, 52 - "hash": "41661dd412f5ae03fdf1e754a6011dd755ffeed6573029a726accb441329d1ad" 53 - }
+25
.sqlx/query-503462e658e2d8ebe9bdb3f22f486ad7744200182214262f9892c0e772a72450.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE repository\nSET\n cid = $3,\n name = $4,\n knot = $5,\n spindle = $6,\n description = $7,\n website = $8,\n topics = $9,\n source = $10,\n labels = $11,\n created_at = $12\nWHERE did = $1 AND rkey = $2\n", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Text", 11 + "Text", 12 + "Text", 13 + "Text", 14 + "Text", 15 + "Text", 16 + "TextArray", 17 + "Text", 18 + "TextArray", 19 + "Timestamptz" 20 + ] 21 + }, 22 + "nullable": [] 23 + }, 24 + "hash": "503462e658e2d8ebe9bdb3f22f486ad7744200182214262f9892c0e772a72450" 25 + }
+59
.sqlx/query-6cf7d307f8c56a10893f384c52e5c1ecfd826dcfa5c3dcf3b07e05599e85a5e0.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO repository\n (did, rkey, cid, name, knot, spindle, description, website, topics, source, labels, created_at, xrpc_create_at, jetstream_at)\nVALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\nON CONFLICT\n ON CONSTRAINT repository_pkey\n DO UPDATE\n SET jetstream_at = $14 WHERE repository.jetstream_at IS NULL\n RETURNING\n name,\n OLD.xrpc_create_at AS old_xrpc_create_at,\n NEW.xrpc_create_at AS new_xrpc_create_at,\n OLD.jetstream_at as old_jetstream_at,\n NEW.jetstream_at AS new_jetstream_at\n", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "name", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "old_xrpc_create_at", 14 + "type_info": "Timestamptz" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "new_xrpc_create_at", 19 + "type_info": "Timestamptz" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "old_jetstream_at", 24 + "type_info": "Timestamptz" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "new_jetstream_at", 29 + "type_info": "Timestamptz" 30 + } 31 + ], 32 + "parameters": { 33 + "Left": [ 34 + "Text", 35 + "Text", 36 + "Text", 37 + "Text", 38 + "Text", 39 + "Text", 40 + "Text", 41 + "Text", 42 + "TextArray", 43 + "Text", 44 + "TextArray", 45 + "Timestamptz", 46 + "Timestamptz", 47 + "Timestamptz" 48 + ] 49 + }, 50 + "nullable": [ 51 + false, 52 + true, 53 + true, 54 + true, 55 + true 56 + ] 57 + }, 58 + "hash": "6cf7d307f8c56a10893f384c52e5c1ecfd826dcfa5c3dcf3b07e05599e85a5e0" 59 + }
+23
.sqlx/query-789b78b11b38f55943a17f364c2000d9fa39b6ec75520805ff1e6e7aacb04297.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT member_did FROM repository_member WHERE repo_did = $1 AND repo_rkey = $2", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "member_did", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + "Text" 16 + ] 17 + }, 18 + "nullable": [ 19 + false 20 + ] 21 + }, 22 + "hash": "789b78b11b38f55943a17f364c2000d9fa39b6ec75520805ff1e6e7aacb04297" 23 + }
+59
.sqlx/query-78db62d7d801f1b7a69ccde8738bfaa45b4c0899e2488692144e342686930147.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO repository\n (did, rkey, cid, name, knot, spindle, description, website, topics, source, labels, created_at, xrpc_create_at, jetstream_at)\nVALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\nON CONFLICT\n ON CONSTRAINT repository_pkey\n DO UPDATE\n SET xrpc_create_at = $14 WHERE repository.xrpc_create_at IS NULL\n RETURNING\n name,\n OLD.xrpc_create_at AS old_xrpc_create_at,\n NEW.xrpc_create_at AS new_xrpc_create_at,\n OLD.jetstream_at as old_jetstream_at,\n NEW.jetstream_at AS new_jetstream_at\n", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "name", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "old_xrpc_create_at", 14 + "type_info": "Timestamptz" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "new_xrpc_create_at", 19 + "type_info": "Timestamptz" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "old_jetstream_at", 24 + "type_info": "Timestamptz" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "new_jetstream_at", 29 + "type_info": "Timestamptz" 30 + } 31 + ], 32 + "parameters": { 33 + "Left": [ 34 + "Text", 35 + "Text", 36 + "Text", 37 + "Text", 38 + "Text", 39 + "Text", 40 + "Text", 41 + "Text", 42 + "TextArray", 43 + "Text", 44 + "TextArray", 45 + "Timestamptz", 46 + "Timestamptz", 47 + "Timestamptz" 48 + ] 49 + }, 50 + "nullable": [ 51 + false, 52 + true, 53 + true, 54 + true, 55 + true 56 + ] 57 + }, 58 + "hash": "78db62d7d801f1b7a69ccde8738bfaa45b4c0899e2488692144e342686930147" 59 + }
+3 -3
crates/knot/Cargo.toml
··· 34 34 data-encoding.workspace = true 35 35 futures-util = "0.3.31" 36 36 hyper-util = { version = "0.1.17", features = ["client"] } 37 + rayon = "1.11.0" 37 38 rustc-hash = "2.1.1" 39 + sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-native-tls", "postgres", "time", "json", "macros", "derive"] } 38 40 time.workspace = true 39 41 tokio = { version = "1.47.1", features = ["io-util", "macros", "net", "process", "signal", "rt-multi-thread"] } 42 + tokio-rayon = "2.1.0" 40 43 tokio-stream = { version = "0.1.17", features = ["time"] } 41 44 tokio-tungstenite = "0.28.0" 42 45 tower = { version = "0.5.2", features = ["buffer", "filter", "limit"] } 43 46 tower-http = { version = "0.6.6", features = ["decompression-gzip", "request-id", "trace", "tracing"] } 44 47 tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } 45 - rayon = "1.11.0" 46 - tokio-rayon = "2.1.0" 47 - sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-native-tls", "postgres", "time", "json", "macros", "derive"] } 48 48 49 49 [dev-dependencies] 50 50 http-body-util = "0.1.3"
+2
crates/knot/migrations/20251109121913_members.down.sql
··· 1 + DROP TABLE repository_member; 2 + DROP TABLE knot_member;
+15
crates/knot/migrations/20251109121913_members.up.sql
··· 1 + CREATE TABLE repository_member ( 2 + repo_did text NOT NULL, 3 + repo_rkey text NOT NULL, 4 + member_did text NOT NULL, 5 + 6 + PRIMARY KEY (repo_did, repo_rkey, member_did), 7 + FOREIGN KEY (repo_did, repo_rkey) REFERENCES repository (did, rkey) ON DELETE CASCADE 8 + ); 9 + 10 + CREATE TABLE knot_member ( 11 + instance_name text NOT NULL, 12 + member_did text NOT NULL, 13 + 14 + PRIMARY KEY (instance_name, member_did) 15 + );
+5 -1
crates/knot/sql/insert_repository.sql crates/knot/sql/insert_repository_jetstream.sql
··· 7 7 DO UPDATE 8 8 SET jetstream_at = $14 WHERE repository.jetstream_at IS NULL 9 9 RETURNING 10 - name, xrpc_create_at, OLD.jetstream_at as old_jetstream_at, NEW.jetstream_at AS new_jetstream_at 10 + name, 11 + OLD.xrpc_create_at AS old_xrpc_create_at, 12 + NEW.xrpc_create_at AS new_xrpc_create_at, 13 + OLD.jetstream_at as old_jetstream_at, 14 + NEW.jetstream_at AS new_jetstream_at
+14
crates/knot/sql/insert_repository_xrpc.sql
··· 1 + INSERT INTO repository 2 + (did, rkey, cid, name, knot, spindle, description, website, topics, source, labels, created_at, xrpc_create_at, jetstream_at) 3 + VALUES 4 + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) 5 + ON CONFLICT 6 + ON CONSTRAINT repository_pkey 7 + DO UPDATE 8 + SET xrpc_create_at = $14 WHERE repository.xrpc_create_at IS NULL 9 + RETURNING 10 + name, 11 + OLD.xrpc_create_at AS old_xrpc_create_at, 12 + NEW.xrpc_create_at AS new_xrpc_create_at, 13 + OLD.jetstream_at as old_jetstream_at, 14 + NEW.jetstream_at AS new_jetstream_at
+13
crates/knot/sql/update_repository.sql
··· 1 + UPDATE repository 2 + SET 3 + cid = $3, 4 + name = $4, 5 + knot = $5, 6 + spindle = $6, 7 + description = $7, 8 + website = $8, 9 + topics = $9, 10 + source = $10, 11 + labels = $11, 12 + created_at = $12 13 + WHERE did = $1 AND rkey = $2
+103 -23
crates/knot/src/model.rs
··· 6 6 pub mod repository; 7 7 8 8 use core::ops; 9 - use std::{path::PathBuf, sync::Arc}; 9 + use std::sync::Arc; 10 10 11 11 use atproto::did::Did; 12 12 use axum::{ ··· 20 20 21 21 use crate::{ 22 22 public::git::{Error, GitAuthorization, NotFound}, 23 - services::authorization::AuthorizationClaimsStore, 23 + services::{ 24 + authorization::AuthorizationClaimsStore, 25 + database::DataStoreError, 26 + rbac::{Policy, RepositoryPushPolicy, RepositoryRef}, 27 + }, 24 28 }; 25 29 26 30 pub use knot_state::KnotState; ··· 68 64 } 69 65 } 70 66 71 - pub trait RepositoryManager { 67 + #[derive(Debug, Clone)] 68 + pub struct ResolvedRepoPath { 69 + pub owner: Box<Did>, 70 + pub rkey: Box<str>, 71 + } 72 + 73 + pub trait RepositoryManager: Send + Sync { 72 74 type Error; 73 75 74 - fn resolve_repo_path<'a>(&'a self, repo: &'a RepoPath) -> BoxFuture<'a, Option<PathBuf>>; 76 + fn resolve_repo_path<'a>( 77 + &'a self, 78 + repo: &'a RepoPath, 79 + ) -> BoxFuture<'a, Result<ResolvedRepoPath, Self::Error>>; 75 80 76 81 fn open<'a>( 77 82 &'a self, ··· 89 76 90 77 /// Determine whether the specified account has permission to push to the 91 78 /// specified repository. 92 - fn can_push(&self, _repo: &RepoPath, _did: &Did) -> BoxFuture<'_, bool> { 93 - async { false }.boxed() 94 - } 79 + fn can_push<'s: 'a, 'a>(&'s self, repo: &'a RepoPath, did: &'a Did) -> BoxFuture<'a, bool>; 95 80 } 96 81 97 - impl RepositoryManager for KnotState { 98 - type Error = Box<gix::open::Error>; 82 + #[derive(Debug, thiserror::Error)] 83 + pub enum RepositoryMangerError { 84 + #[error("Failed to resolve identity: {0}")] 85 + Resolve(#[from] identity::ResolveError), 86 + #[error("Failed to lookup respository: {0}")] 87 + Lookup(#[from] DataStoreError), 88 + #[error("Failed to open repository: {0}")] 89 + Open(#[from] gix::open::Error), 90 + #[error("Repository not found")] 91 + NotFound, 92 + } 99 93 100 - fn resolve_repo_path<'a>(&'a self, repo: &'a RepoPath) -> BoxFuture<'a, Option<PathBuf>> { 94 + impl RepositoryManager for Knot { 95 + type Error = RepositoryMangerError; 96 + 97 + fn resolve_repo_path<'a>( 98 + &'a self, 99 + repo: &'a RepoPath, 100 + ) -> BoxFuture<'a, Result<ResolvedRepoPath, Self::Error>> { 101 101 use std::borrow::Cow; 102 102 103 + let cache_key = (repo.owner.clone(), repo.name.clone()); 104 + if let Some(resolved) = self.repo_cache.read().unwrap().get(&cache_key).cloned() { 105 + return async move { Ok(resolved) }.boxed(); 106 + } 107 + 103 108 async move { 104 - let repo_owner_did = match Did::parse(&repo.owner) { 109 + let owner = match Did::parse(&repo.owner) { 105 110 Ok(did) => Cow::Borrowed(did), 106 111 Err(_) => { 107 112 // Assume the repo owner is a handle. 108 - let (did, _) = self.resolver().resolve(&repo.owner).await.ok()?; 113 + let (did, _) = self.resolver().resolve(&repo.owner).await?; 109 114 Cow::Owned(did) 110 115 } 111 116 }; 112 117 113 - Some( 114 - self.repository_path() 115 - .join(repo_owner_did.as_str()) 116 - .join(repo.name()), 117 - ) 118 + let rkey = self 119 + .store() 120 + .resolve_repository(&owner, repo.name()) 121 + .await? 122 + .ok_or(RepositoryMangerError::NotFound)?; 123 + 124 + let resolved = ResolvedRepoPath { 125 + owner: owner.into_owned(), 126 + rkey, 127 + }; 128 + 129 + self.repo_cache 130 + .write() 131 + .unwrap() 132 + .insert(cache_key, resolved.clone()); 133 + 134 + Ok(resolved) 118 135 } 119 136 .boxed() 120 137 } ··· 155 112 ) -> BoxFuture<'a, Result<gix::Repository, Self::Error>> { 156 113 use gix::open::Options; 157 114 115 + let path = self.repository_path().join(repo.owner()).join(repo.name()); 116 + if let Ok(true) = std::fs::exists(&path) { 117 + return async move { 118 + let repository = Options::default() 119 + .strict_config(true) 120 + .open_path_as_is(true) 121 + .open(path)?; 122 + 123 + let local = repository.to_thread_local(); 124 + assert!(local.is_bare()); 125 + 126 + Ok(local) 127 + } 128 + .boxed(); 129 + } 130 + 158 131 async move { 132 + let resolved = self.resolve_repo_path(repo).await?; 133 + 159 134 let path = self 160 - .resolve_repo_path(repo) 161 - .await 162 - .ok_or(Box::new(gix::open::Error::Io(std::io::Error::new( 163 - std::io::ErrorKind::NotFound, 164 - format!("failed to resolve repository owner '{}'", repo.owner), 165 - ))))?; 135 + .repository_path() 136 + .join(resolved.owner.as_str()) 137 + .join(resolved.rkey.as_ref()); 166 138 167 139 let repository = Options::default() 168 140 .strict_config(true) ··· 188 130 assert!(local.is_bare()); 189 131 190 132 Ok(local) 133 + } 134 + .boxed() 135 + } 136 + 137 + fn can_push<'s: 'a, 'a>(&'s self, repo: &'a RepoPath, did: &'a Did) -> BoxFuture<'a, bool> { 138 + async move { 139 + if let Ok(resolved) = self.resolve_repo_path(repo).await { 140 + let policy = RepositoryPushPolicy; 141 + let repository = RepositoryRef::new(&resolved.owner, &resolved.rkey); 142 + let result = policy 143 + .evaluate_access( 144 + &did, 145 + &crate::services::rbac::Action::RepositoryPush, 146 + &repository, 147 + self, 148 + ) 149 + .await; 150 + 151 + return matches!(result, crate::services::rbac::PolicyResult::Granted); 152 + } 153 + 154 + false 191 155 } 192 156 .boxed() 193 157 }
+66 -3
crates/knot/src/model/knot_state.rs
··· 1 1 use std::{ 2 2 collections::HashMap, 3 3 ops, 4 - sync::{Arc, Mutex, MutexGuard}, 4 + sync::{Arc, Mutex, MutexGuard, RwLock}, 5 5 time::Duration, 6 6 }; 7 7 8 8 use atproto::did::Did; 9 + use bytes::Bytes; 9 10 use identity::{HttpClient, Resolver}; 10 11 use jetstream::JetstreamClient; 11 12 use lexicon::com::atproto::repo::list_records; ··· 17 16 18 17 use crate::services::{authorization::AuthorizationClaimsStore, database::DataStore}; 19 18 20 - use super::config::KnotConfiguration; 19 + use super::{ResolvedRepoPath, config::KnotConfiguration}; 21 20 22 21 #[derive(Debug)] 23 22 pub struct KnotState { 24 23 config: KnotConfiguration, 25 24 jwt_claims: Mutex<HashMap<Box<str>, oauth::jwt::Claims>>, 25 + pub(crate) repo_cache: RwLock<HashMap<(Box<str>, Box<str>), ResolvedRepoPath>>, 26 26 public_http: reqwest::Client, 27 27 resolver: Resolver, 28 28 _jetstream: JetstreamClient, ··· 45 43 46 44 let inner = Arc::new(Self { 47 45 config, 46 + jwt_claims: Default::default(), 47 + repo_cache: Default::default(), 48 48 public_http, 49 49 resolver, 50 50 _jetstream: jetstream, 51 51 store: database, 52 - jwt_claims: Default::default(), 53 52 pool, 54 53 }); 55 54 ··· 184 181 .ok() 185 182 }) 186 183 .collect()) 184 + } 185 + 186 + pub async fn fetch_pds_record( 187 + &self, 188 + did: &Did, 189 + collection: &str, 190 + rkey: &str, 191 + ) -> anyhow::Result<Bytes> { 192 + use url::Url; 193 + 194 + fn get_record_url(mut pds: Url, repo: &Did, collection: &str, rkey: &str) -> Url { 195 + pds.set_path("/xrpc/com.atproto.repo.getRecord"); 196 + let mut query = pds.query_pairs_mut(); 197 + query.append_pair("repo", repo.as_str()); 198 + query.append_pair("collection", collection); 199 + query.append_pair("rkey", rkey); 200 + drop(query); 201 + pds 202 + } 203 + 204 + let (_, doc) = self.resolver.resolve(did.as_str()).await?; 205 + let pds = &doc 206 + .atproto_pds() 207 + .ok_or(anyhow::anyhow!("DID document does not declare a pds"))? 208 + .service_endpoint; 209 + 210 + let response = self 211 + .public_http 212 + .get(get_record_url(pds.clone(), did, collection, rkey)) 213 + .send() 214 + .await? 215 + .error_for_status()? 216 + .bytes() 217 + .await?; 218 + 219 + Ok(response) 220 + } 221 + 222 + pub fn create_repo(&self, did: &Did, rkey: &str, name: &str) -> anyhow::Result<()> { 223 + let path = self.repository_path().join(did.as_str()).join(rkey); 224 + let repo = gix::init_bare(&path)?; 225 + tracing::info!(?repo, "created repository"); 226 + 227 + // Create a symlink to map the repository name -> rkey. 228 + let symlink_path = self.repository_path().join(did.as_str()).join(name); 229 + let _ = std::fs::remove_file(&symlink_path); 230 + std::os::unix::fs::symlink(rkey, &symlink_path)?; 231 + 232 + Ok(()) 233 + } 234 + 235 + pub fn delete_repo(&self, did: &Did, rkey: &str) -> anyhow::Result<()> { 236 + let path = self.repository_path().join(did.as_str()).join(rkey); 237 + 238 + let mut delete_path = self.repository_path().join("deleted"); 239 + let _ = std::fs::create_dir_all(&delete_path); 240 + 241 + delete_path.push(format!("{}-{}", did, rkey)); 242 + std::fs::rename(&path, &delete_path)?; 243 + Ok(()) 187 244 } 188 245 } 189 246
+1
crates/knot/src/public/xrpc.rs
··· 26 26 .route("/sh.tangled.repo.blob", get(repo::handle_blob)) 27 27 .route("/sh.tangled.repo.branches", get(repo::handle_branches)) 28 28 .route("/sh.tangled.repo.create", post(repo::handle_create)) 29 + .route("/sh.tangled.repo.delete", post(repo::handle_delete)) 29 30 .route("/sh.tangled.repo.diff", get(repo::handle_diff)) 30 31 .route("/sh.tangled.repo.getDefaultBranch", get(repo::handle_get_default_branch)) 31 32 .route("/sh.tangled.repo.languages", get(repo::handle_languages))
+88 -5
crates/knot/src/public/xrpc/sh/tangled/repo.rs
··· 1 1 use axum::{Json, extract::State}; 2 - use lexicon::sh::tangled::repo::{create, get_default_branch, languages, tree}; 2 + use lexicon::sh::tangled::repo::{create, delete, get_default_branch, languages, tree}; 3 3 use tokio_rayon::AsyncThreadPool as _; 4 4 5 5 use crate::{ 6 - model::{Knot, repository::GixRepository}, 6 + model::{Knot, errors, repository::GixRepository}, 7 7 public::xrpc::{XrpcQuery, XrpcResult}, 8 8 services::authorization::{Authorization, Verification}, 9 9 types::sh::tangled::repo::{blob, branches, diff, log, tags}, ··· 40 40 41 41 /// Handler for XRPC procedure `sh.tangled.repo.create`. 42 42 pub async fn handle_create( 43 - State(_knot): State<Knot>, 43 + State(knot): State<Knot>, 44 44 authorization: Authorization<CreateVerification>, 45 45 Json(params): Json<create::Input<'static>>, 46 46 ) -> XrpcResult<()> { 47 - let claims = authorization.claims(); 47 + use crate::services::rbac::{Action, Policy, PolicyResult::*, RepositoryCreatePolicy}; 48 48 49 - tracing::info!(?params, ?claims); 49 + let claims = authorization.claims(); 50 + let policy = RepositoryCreatePolicy; 51 + let can_create = policy 52 + .evaluate_access( 53 + &claims.iss.as_ref(), 54 + &Action::RepositoryCreate, 55 + &knot, 56 + &knot, 57 + ) 58 + .await; 59 + 60 + if !matches!(can_create, Granted) { 61 + return Err(errors::Forbidden(format!( 62 + "'{}' does not have permission to create repositories on this knot", 63 + claims.iss 64 + )))?; 65 + } 66 + 67 + // Fetch repository record from pds. 68 + let response = knot 69 + .fetch_pds_record(&claims.iss, "sh.tangled.repo", &params.rkey) 70 + .await 71 + .map_err(errors::RepoError)?; 72 + 73 + let record = serde_json::from_slice(&response).map_err(errors::RepoError)?; 74 + 75 + let is_new = knot 76 + .store() 77 + .insert_repository_from_record(&record) 78 + .await 79 + .map_err(errors::RepoError)?; 80 + 81 + if is_new && record.value.knot == knot.instance_name() { 82 + knot.create_repo(&claims.iss, &params.rkey, &record.value.name) 83 + .map_err(errors::RepoError)?; 84 + } 85 + 86 + Ok(().into()) 87 + } 88 + 89 + #[derive(Debug, Default)] 90 + pub struct DeleteVerification; 91 + 92 + impl Verification for DeleteVerification { 93 + const LEXICON_METHOD: &'static str = "sh.tangled.repo.delete"; 94 + } 95 + 96 + /// Handler for XRPC procedure `sh.tangled.repo.delete`. 97 + pub async fn handle_delete( 98 + State(knot): State<Knot>, 99 + authorization: Authorization<DeleteVerification>, 100 + Json(params): Json<delete::Input<'static>>, 101 + ) -> XrpcResult<()> { 102 + use crate::services::rbac::{ 103 + Action, Policy, PolicyResult::*, RepositoryDeletePolicy, RepositoryRef, 104 + }; 105 + 106 + let claims = authorization.claims(); 107 + let policy = RepositoryDeletePolicy; 108 + let repository = RepositoryRef::new(&params.did, &params.rkey); 109 + let can_delete = policy 110 + .evaluate_access( 111 + &claims.iss.as_ref(), 112 + &Action::RepositoryDelete, 113 + &repository, 114 + &knot, 115 + ) 116 + .await; 117 + 118 + if !matches!(can_delete, Granted) { 119 + return Err(errors::Forbidden(format!( 120 + "'{}' does not have permission to delete repository '{}/{}'", 121 + claims.iss, params.did, params.rkey 122 + )))?; 123 + } 124 + 125 + knot.store() 126 + .delete_repository(&params.did, &params.rkey) 127 + .await 128 + .map_err(errors::RepoError)?; 129 + 130 + knot.delete_repo(&params.did, &params.rkey) 131 + .map_err(errors::RepoError)?; 132 + 50 133 Ok(().into()) 51 134 } 52 135
+1
crates/knot/src/services.rs
··· 1 1 pub mod authorization; 2 2 pub mod database; 3 3 pub mod jetstream; 4 + pub mod rbac;
+112 -1
crates/knot/src/services/database.rs
··· 59 59 xrpc_create_at: Option<&'a OffsetDateTime>, 60 60 jetstream_at: Option<&'a OffsetDateTime>, 61 61 ) -> BoxFuture<'a, Result<Option<InsertRepositoryResult>, Self::Error>>; 62 + 63 + fn update_repository<'d: 'a, 'a>( 64 + &'d self, 65 + did: &'a Did, 66 + rkey: &'a str, 67 + cid: &'a str, 68 + repo: &'a Repo<'a>, 69 + ) -> BoxFuture<'a, Result<(), Self::Error>>; 70 + 71 + fn delete_repository<'d: 'a, 'a>( 72 + &'d self, 73 + did: &'a Did, 74 + rkey: &'a str, 75 + ) -> BoxFuture<'a, Result<(), Self::Error>>; 76 + 77 + fn resolve_repository<'d: 'a, 'a>( 78 + &'d self, 79 + did: &'a Did, 80 + name_or_rkey: &'a str, 81 + ) -> BoxFuture<'a, Result<Option<Box<str>>, Self::Error>>; 82 + 83 + fn repository_members<'d: 'a, 'a>( 84 + &'d self, 85 + did: &'a Did, 86 + rkey: &'a str, 87 + ) -> BoxStream<'a, Result<Box<Did>, Self::Error>>; 88 + 89 + fn knot_members<'d: 'a, 'a>( 90 + &'d self, 91 + instance_name: &'a str, 92 + ) -> BoxStream<'a, Result<Box<Did>, Self::Error>>; 62 93 } 63 94 64 95 #[derive(Debug, thiserror::Error)] 65 96 pub enum DataStoreError { 66 97 #[error("Database error: {0}")] 67 98 Sqlx(#[from] sqlx::Error), 99 + #[error("Failed to parse DID from db: {0}")] 100 + Did(#[from] atproto::did::Error), 68 101 #[error("Failed to extract AT-URI: {0}")] 69 102 AtUri(#[from] atproto::aturi::Error), 70 103 #[error("{0}")] ··· 209 176 let is_new = match result { 210 177 None => false, 211 178 Some(result) => { 212 - result.xrpc_create_at.is_none() 179 + result.old_xrpc_create_at.is_none() 180 + && result.new_xrpc_create_at.is_none() 213 181 && result.old_jetstream_at.is_none() 214 182 && result.new_jetstream_at.is_some() 215 183 } 216 184 }; 217 185 218 186 Ok(is_new) 187 + } 188 + 189 + pub async fn insert_repository_from_record( 190 + &self, 191 + record: &Record<'_, Repo<'_>>, 192 + ) -> Result<bool, DataStoreError> { 193 + let uri = record.aturi()?; 194 + let result = self 195 + .inner 196 + .insert_repository( 197 + uri.did() 198 + .ok_or(anyhow::anyhow!("AT-URI with DID authority required"))?, 199 + uri.rkey.ok_or(anyhow::anyhow!("Missing rkey"))?, 200 + &record.cid, 201 + &record.value, 202 + Some(&OffsetDateTime::now_utc()), 203 + None, 204 + ) 205 + .await?; 206 + 207 + let is_new = match result { 208 + None => false, 209 + Some(result) => { 210 + result.old_xrpc_create_at.is_none() 211 + && result.new_xrpc_create_at.is_some() 212 + && result.old_jetstream_at.is_none() 213 + && result.new_jetstream_at.is_none() 214 + } 215 + }; 216 + 217 + Ok(is_new) 218 + } 219 + 220 + pub async fn update_repository( 221 + &self, 222 + did: &Did, 223 + rkey: &str, 224 + cid: &str, 225 + repository: &Repo<'_>, 226 + ) -> Result<(), DataStoreError> { 227 + self.inner 228 + .update_repository(did, rkey, cid, repository) 229 + .await?; 230 + Ok(()) 231 + } 232 + 233 + pub async fn delete_repository(&self, did: &Did, rkey: &str) -> Result<(), DataStoreError> { 234 + self.inner.delete_repository(did, rkey).await?; 235 + Ok(()) 236 + } 237 + 238 + pub async fn resolve_repository( 239 + &self, 240 + did: &Did, 241 + name_or_rkey: &str, 242 + ) -> Result<Option<Box<str>>, DataStoreError> { 243 + let rkey = self.inner.resolve_repository(did, name_or_rkey).await?; 244 + Ok(rkey) 245 + } 246 + 247 + pub async fn is_repository_member( 248 + &self, 249 + repo_did: &Did, 250 + repo_rkey: &str, 251 + member_did: &Did, 252 + ) -> bool { 253 + self.inner 254 + .repository_members(repo_did, repo_rkey) 255 + .any(|did| async move { did.is_ok_and(|value| value == member_did) }) 256 + .await 257 + } 258 + 259 + pub async fn is_knot_member(&self, instance_name: &str, did: &Did) -> bool { 260 + self.inner 261 + .knot_members(instance_name) 262 + .any(|member_did| async move { member_did.is_ok_and(|value| value == did) }) 263 + .await 219 264 } 220 265 }
+166 -6
crates/knot/src/services/database/pg_impl.rs
··· 164 164 .map(|label| label.clone().into()) 165 165 .collect(); 166 166 167 + match (xrpc_create_at, jetstream_at) { 168 + (Some(xrpc_ts), None) => async move { 169 + let result = sqlx::query_file_as!( 170 + InsertRepositoryResult, 171 + "sql/insert_repository_xrpc.sql", 172 + did.as_str(), 173 + rkey, 174 + cid, 175 + &repo.name, 176 + &repo.knot, 177 + repo.spindle.as_deref(), 178 + repo.description.as_deref(), 179 + repo.website.as_deref(), 180 + topics.as_slice(), 181 + repo.source.as_deref(), 182 + labels.as_slice(), 183 + &repo.created_at, 184 + Some(xrpc_ts), 185 + None::<OffsetDateTime>, 186 + ) 187 + .fetch_optional(&self.pool) 188 + .await?; 189 + 190 + Ok(result) 191 + } 192 + .boxed(), 193 + (None, Some(jetstream_ts)) => async move { 194 + let result = sqlx::query_file_as!( 195 + InsertRepositoryResult, 196 + "sql/insert_repository_jetstream.sql", 197 + did.as_str(), 198 + rkey, 199 + cid, 200 + &repo.name, 201 + &repo.knot, 202 + repo.spindle.as_deref(), 203 + repo.description.as_deref(), 204 + repo.website.as_deref(), 205 + topics.as_slice(), 206 + repo.source.as_deref(), 207 + labels.as_slice(), 208 + &repo.created_at, 209 + None::<OffsetDateTime>, 210 + Some(jetstream_ts), 211 + ) 212 + .fetch_optional(&self.pool) 213 + .await?; 214 + 215 + Ok(result) 216 + } 217 + .boxed(), 218 + (Some(_), Some(_)) | (None, None) => panic!(), 219 + } 220 + } 221 + 222 + fn update_repository<'d: 'a, 'a>( 223 + &'d self, 224 + did: &'a Did, 225 + rkey: &'a str, 226 + cid: &'a str, 227 + repo: &'a Repo<'a>, 228 + ) -> BoxFuture<'a, Result<(), Self::Error>> { 229 + let topics: Vec<String> = repo 230 + .topics 231 + .iter() 232 + .map(|topic| topic.clone().into()) 233 + .collect(); 234 + 235 + let labels: Vec<String> = repo 236 + .labels 237 + .iter() 238 + .map(|label| label.clone().into()) 239 + .collect(); 240 + 167 241 async move { 168 - let result = sqlx::query_file_as!( 169 - InsertRepositoryResult, 170 - "sql/insert_repository.sql", 242 + sqlx::query_file!( 243 + "sql/update_repository.sql", 171 244 did.as_str(), 172 245 rkey, 173 246 cid, ··· 253 180 repo.source.as_deref(), 254 181 labels.as_slice(), 255 182 &repo.created_at, 256 - xrpc_create_at, 257 - jetstream_at, 183 + ) 184 + .execute(&self.pool) 185 + .await?; 186 + 187 + Ok(()) 188 + } 189 + .boxed() 190 + } 191 + 192 + fn delete_repository<'d: 'a, 'a>( 193 + &'d self, 194 + did: &'a Did, 195 + rkey: &'a str, 196 + ) -> BoxFuture<'a, Result<(), Self::Error>> { 197 + async move { 198 + sqlx::query!( 199 + "DELETE FROM repository WHERE did = $1 AND rkey = $2", 200 + did.as_str(), 201 + rkey 202 + ) 203 + .execute(&self.pool) 204 + .await?; 205 + Ok(()) 206 + } 207 + .boxed() 208 + } 209 + 210 + fn resolve_repository<'d: 'a, 'a>( 211 + &'d self, 212 + did: &'a Did, 213 + name_or_rkey: &'a str, 214 + ) -> BoxFuture<'a, Result<Option<Box<str>>, Self::Error>> { 215 + async move { 216 + #[derive(sqlx::FromRow)] 217 + struct Record { 218 + rkey: Box<str>, 219 + } 220 + 221 + let result: Option<Record> = sqlx::query_as!( 222 + Record, 223 + "SELECT rkey FROM repository WHERE did = $1 AND (rkey = $2 OR name = $2)", 224 + did.as_str(), 225 + name_or_rkey 258 226 ) 259 227 .fetch_optional(&self.pool) 260 228 .await?; 261 229 262 - Ok(result) 230 + Ok(result.map(|record| record.rkey)) 263 231 } 232 + .boxed() 233 + } 234 + 235 + fn repository_members<'d: 'a, 'a>( 236 + &'d self, 237 + did: &'a Did, 238 + rkey: &'a str, 239 + ) -> BoxStream<'a, Result<Box<Did>, Self::Error>> { 240 + #[derive(sqlx::FromRow)] 241 + struct Record { 242 + member_did: Box<str>, 243 + } 244 + 245 + sqlx::query_as!( 246 + Record, 247 + "SELECT member_did FROM repository_member WHERE repo_did = $1 AND repo_rkey = $2", 248 + did.as_str(), 249 + rkey 250 + ) 251 + .fetch(&self.pool) 252 + .map(|record| { 253 + let did = record?.member_did.parse()?; 254 + Ok(did) 255 + }) 256 + .boxed() 257 + } 258 + 259 + fn knot_members<'d: 'a, 'a>( 260 + &'d self, 261 + instance_name: &'a str, 262 + ) -> BoxStream<'a, Result<Box<Did>, Self::Error>> { 263 + #[derive(sqlx::FromRow)] 264 + struct Record { 265 + member_did: Box<str>, 266 + } 267 + 268 + sqlx::query_as!( 269 + Record, 270 + "SELECT member_did FROM knot_member WHERE instance_name = $1", 271 + instance_name 272 + ) 273 + .fetch(&self.pool) 274 + .map(|record| { 275 + let did = record?.member_did.parse()?; 276 + Ok(did) 277 + }) 264 278 .boxed() 265 279 } 266 280 }
+2 -1
crates/knot/src/services/database/types.rs
··· 106 106 #[derive(Debug, sqlx::FromRow)] 107 107 pub struct InsertRepositoryResult { 108 108 pub name: String, 109 - pub xrpc_create_at: Option<OffsetDateTime>, 109 + pub old_xrpc_create_at: Option<OffsetDateTime>, 110 + pub new_xrpc_create_at: Option<OffsetDateTime>, 110 111 pub old_jetstream_at: Option<OffsetDateTime>, 111 112 pub new_jetstream_at: Option<OffsetDateTime>, 112 113 }
+44 -21
crates/knot/src/services/jetstream.rs
··· 1 1 use super::database::DataStore; 2 - use crate::model::KnotState; 2 + use crate::{ 3 + model::KnotState, 4 + services::rbac::{Policy, RepositoryCreatePolicy, RepositoryDeletePolicy, RepositoryRef}, 5 + }; 3 6 use jetstream::{CommitEvent, Event}; 4 7 use lexicon::Lexicon; 5 8 use std::sync::Arc; ··· 83 80 } 84 81 85 82 async fn process_repo(state: &KnotState, event: &CommitEvent<'_>) -> anyhow::Result<()> { 83 + use crate::services::rbac::{Action, PolicyResult::*}; 84 + 86 85 match event { 87 86 CommitEvent::Create(commit) => { 88 87 let Lexicon::Repo(repository) = serde_json::from_str(commit.record.get())? else { 89 88 return Err(anyhow::anyhow!("expected a 'sh.tangled.repo' record")); 90 89 }; 90 + 91 + let policy = RepositoryCreatePolicy; 92 + let can_create = policy 93 + .evaluate_access(&commit.did, &Action::RepositoryCreate, state, state) 94 + .await; 95 + 96 + if !matches!(can_create, Granted) { 97 + tracing::warn!(?commit, "RepositoryCreate permission denied"); 98 + return Ok(()); 99 + } 91 100 92 101 let is_new = state 93 102 .store() ··· 126 111 return Ok(()); 127 112 } 128 113 129 - // @TODO Verify DID in commit has permission to create repositories on this knot. 130 - 131 - let path = state 132 - .repository_path() 133 - .join(commit.did.as_str()) 134 - .join(commit.rkey); 135 - 136 - let repo = gix::init_bare(&path)?; 137 - tracing::info!(?repo, "created repository"); 138 - 139 - // Create a symlink to map the repository name -> rkey. 140 - let symlink_path = state 141 - .repository_path() 142 - .join(commit.did.as_str()) 143 - .join(&*repository.name); 144 - 145 - std::os::unix::fs::symlink(commit.rkey, &symlink_path)?; 114 + state.create_repo(commit.did, commit.rkey, &repository.name)?; 146 115 } 147 116 CommitEvent::Update(commit) => { 148 - tracing::info!("would update repo: {commit:?}"); 149 - // 117 + let Lexicon::Repo(repository) = serde_json::from_str(commit.record.get())? else { 118 + return Err(anyhow::anyhow!("expected a 'sh.tangled.repo' record")); 119 + }; 120 + 121 + // @TODO Does this need auth? 122 + 123 + state 124 + .store() 125 + .update_repository(commit.did, commit.rkey, commit.cid, &repository) 126 + .await?; 150 127 } 151 128 CommitEvent::Delete(delete) => { 152 - tracing::info!("would delete repo: {delete:?}"); 129 + let policy = RepositoryDeletePolicy; 130 + let repository = RepositoryRef::new(delete.did, delete.rkey); 131 + let can_create = policy 132 + .evaluate_access(&delete.did, &Action::RepositoryDelete, &repository, state) 133 + .await; 134 + 135 + if !matches!(can_create, Granted) { 136 + tracing::warn!(?delete, "RepositoryDelete permission denied"); 137 + return Ok(()); 138 + } 139 + 140 + state 141 + .store() 142 + .delete_repository(delete.did, delete.rkey) 143 + .await?; 144 + 145 + state.delete_repo(delete.did, delete.rkey)?; 153 146 } 154 147 } 155 148
+134
crates/knot/src/services/rbac.rs
··· 1 + use atproto::Did; 2 + use futures_util::{FutureExt, future::BoxFuture}; 3 + 4 + use crate::model::{Knot, KnotState}; 5 + 6 + pub trait Policy<Subject, Resource, Action, Context>: Send + Sync { 7 + /// Evaluates whether access should be granted. 8 + /// 9 + /// # Arguments 10 + /// 11 + /// * `subject` - The entity requesting access. 12 + /// * `action` - The action being performed. 13 + /// * `resource` - The target resource. 14 + /// * `context` - Additional context that may affect the decision. 15 + /// 16 + /// # Returns 17 + /// 18 + /// A [`PolicyResult`] indicating whether access is granted or denied. 19 + fn evaluate_access<'s: 'a, 'a>( 20 + &'s self, 21 + subject: &'a Subject, 22 + action: &'a Action, 23 + resource: &'a Resource, 24 + context: &'a Context, 25 + ) -> BoxFuture<'a, PolicyResult>; 26 + } 27 + 28 + impl<S, R, A, C> Policy<S, R, A, C> for Box<dyn Policy<S, R, A, C>> { 29 + #[inline] 30 + fn evaluate_access<'s: 'a, 'a>( 31 + &'s self, 32 + subject: &'a S, 33 + action: &'a A, 34 + resource: &'a R, 35 + context: &'a C, 36 + ) -> BoxFuture<'a, PolicyResult> { 37 + (**self).evaluate_access(subject, action, resource, context) 38 + } 39 + } 40 + 41 + pub enum PolicyResult { 42 + Granted, 43 + Denied, 44 + } 45 + 46 + pub enum Action { 47 + RepositoryCreate, 48 + RepositoryDelete, 49 + RepositoryPush, 50 + } 51 + 52 + pub struct RepositoryPushPolicy; 53 + 54 + pub struct RepositoryRef<'a> { 55 + pub did: &'a Did, 56 + pub rkey: &'a str, 57 + } 58 + 59 + impl<'a> RepositoryRef<'a> { 60 + pub fn new(did: &'a Did, rkey: &'a str) -> Self { 61 + Self { did, rkey } 62 + } 63 + } 64 + 65 + impl Policy<&Did, RepositoryRef<'_>, Action, Knot> for RepositoryPushPolicy { 66 + fn evaluate_access<'s: 'a, 'a>( 67 + &'s self, 68 + &subject: &'a &Did, 69 + action: &'a Action, 70 + resource: &'a RepositoryRef<'_>, 71 + context: &'a Knot, 72 + ) -> BoxFuture<'a, PolicyResult> { 73 + async move { 74 + let is_member = subject == resource.did 75 + || context 76 + .store() 77 + .is_repository_member(resource.did, resource.rkey, subject) 78 + .await; 79 + 80 + match (action, is_member) { 81 + (Action::RepositoryPush, true) => PolicyResult::Granted, 82 + (_, _) => PolicyResult::Denied, 83 + } 84 + } 85 + .boxed() 86 + } 87 + } 88 + 89 + pub struct RepositoryCreatePolicy; 90 + 91 + impl Policy<&Did, KnotState, Action, KnotState> for RepositoryCreatePolicy { 92 + fn evaluate_access<'s: 'a, 'a>( 93 + &'s self, 94 + &subject: &'a &Did, 95 + action: &'a Action, 96 + resource: &'a KnotState, 97 + context: &'a KnotState, 98 + ) -> BoxFuture<'a, PolicyResult> { 99 + async move { 100 + let is_member = subject == resource.owner_did() 101 + || context 102 + .store() 103 + .is_knot_member(context.instance_name(), subject) 104 + .await; 105 + 106 + match (action, is_member) { 107 + (Action::RepositoryCreate, true) => PolicyResult::Granted, 108 + (_, _) => PolicyResult::Denied, 109 + } 110 + } 111 + .boxed() 112 + } 113 + } 114 + 115 + pub struct RepositoryDeletePolicy; 116 + 117 + impl Policy<&Did, RepositoryRef<'_>, Action, KnotState> for RepositoryDeletePolicy { 118 + fn evaluate_access<'s: 'a, 'a>( 119 + &'s self, 120 + &subject: &'a &Did, 121 + action: &'a Action, 122 + resource: &'a RepositoryRef<'_>, 123 + context: &'a KnotState, 124 + ) -> BoxFuture<'a, PolicyResult> { 125 + async move { 126 + let is_owner = subject == context.owner_did() || resource.did == subject; 127 + match (action, is_owner) { 128 + (Action::RepositoryDelete, true) => PolicyResult::Granted, 129 + (_, _) => PolicyResult::Denied, 130 + } 131 + } 132 + .boxed() 133 + } 134 + }
+1
crates/lexicon/src/sh/tangled/repo.rs
··· 1 1 pub mod blob; 2 2 pub mod branches; 3 3 pub mod create; 4 + pub mod delete; 4 5 pub mod diff; 5 6 pub mod get_default_branch; 6 7 pub mod issue;
+44
crates/lexicon/src/sh/tangled/repo/delete.rs
··· 1 + //! 2 + //! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/delete.json> 3 + //! 4 + use std::borrow::Cow; 5 + 6 + use atproto::Did; 7 + use serde::{Deserialize, Serialize}; 8 + 9 + /// Parameters for the `sh.tangled.repo.delete` procedure. 10 + /// 11 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/delete.json> 12 + #[derive(Debug, Deserialize, Serialize)] 13 + #[serde(rename_all = "camelCase")] 14 + pub struct Input<'a> { 15 + /// DID of the repository owner. 16 + pub did: Cow<'a, Did>, 17 + 18 + /// Record key of the repository record. 19 + pub rkey: Cow<'a, str>, 20 + 21 + /// Name of the repository to delete. 22 + pub name: Cow<'a, str>, 23 + } 24 + 25 + #[cfg(test)] 26 + mod tests { 27 + use super::Input; 28 + 29 + #[test] 30 + fn can_deserialize_required() { 31 + const REQUEST: &str = r#"{"rkey":"3m4xo72lo4322"}"#; 32 + 33 + let input: Input = serde_json::from_str(REQUEST).expect("should deserialize"); 34 + assert_eq!(input.rkey, "3m4xo72lo4322"); 35 + } 36 + 37 + #[test] 38 + fn can_deserialize_complete() { 39 + const REQUEST: &str = r#"{"rkey":"3m4xo72lo4322"}"#; 40 + 41 + let input: Input = serde_json::from_str(REQUEST).expect("should deserialize"); 42 + assert_eq!(input.rkey, "3m4xo72lo4322"); 43 + } 44 + }