zuka
zuka/src/account/identity.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/account/identity.rs
RSidentity.rs6.6 KBDownload
1// The resolved caller. Produced by http::auth, consumed by every handler.
2//
3// Access checks live here rather than in the facades, so REST, MCP and SSH cannot
4// drift (SPEC §1.1). A handler asks `identity.require_write(account, repo)?` and
5// never re-derives the rule.
6//
7// Every comparison is against a canonical `Name`, never a raw path segment: a raw
8// segment lets `Alice/site` pass an ownership check while resolving to `alice`'s
9// repository on a case-insensitive filesystem.
10
11use crate::account::token::{Scope, TokenRecord};
12use crate::error::{Error, Result};
13use crate::git::validate::Name;
14
15#[derive(Debug, Clone)]
16pub struct Identity {
17 pub account: Name,
18 pub token_id: String,
19 pub scopes: Vec<Scope>,
20 /// Empty means every repository the account owns.
21 pub repos: Vec<Name>,
22}
23
24impl Identity {
25 pub fn from_token(record: &TokenRecord) -> Result<Self> {
26 let account = crate::git::validate::name(&record.account)?;
27 let repos = record
28 .repos
29 .iter()
30 .map(|r| crate::git::validate::name(r))
31 .collect::<Result<Vec<_>>>()?;
32
33 Ok(Identity {
34 account,
35 token_id: record.id.clone(),
36 scopes: record.scopes.clone(),
37 repos,
38 })
39 }
40
41 fn has_scope(&self, required: Scope) -> bool {
42 self.scopes.iter().any(|s| s.satisfies(required))
43 }
44
45 fn covers_repo(&self, repo: &Name) -> bool {
46 self.repos.is_empty() || self.repos.contains(repo)
47 }
48
49 fn owns(&self, account: &Name) -> bool {
50 &self.account == account
51 }
52
53 pub fn require_read(&self, account: &Name, repo: &Name) -> Result<()> {
54 self.check(account, repo, Scope::RepoRead)
55 }
56
57 pub fn require_write(&self, account: &Name, repo: &Name) -> Result<()> {
58 self.check(account, repo, Scope::RepoWrite)
59 }
60
61 /// Admin over one repository.
62 ///
63 /// Runs the same confinement gate as `check`: a token restricted to `repos` must
64 /// not be able to destroy a repository it is denied read on.
65 pub fn require_repo_admin(&self, account: &Name, repo: &Name) -> Result<()> {
66 self.check(account, repo, Scope::Admin)
67 }
68
69 /// Admin over the account itself, for operations that name no repository.
70 ///
71 /// A repo-confined token never qualifies — its confinement would otherwise be
72 /// bypassed by any account-level operation.
73 pub fn require_account_admin(&self, account: &Name) -> Result<()> {
74 if !self.owns(account) {
75 return Err(Error::NotFound("account"));
76 }
77 if !self.repos.is_empty() {
78 return Err(Error::Forbidden);
79 }
80 if !self.has_scope(Scope::Admin) {
81 return Err(Error::Forbidden);
82 }
83 Ok(())
84 }
85
86 /// A repo the caller cannot reach is reported as absent, not forbidden —
87 /// otherwise the status code enumerates private repositories (SPEC §4.2).
88 fn check(&self, account: &Name, repo: &Name, required: Scope) -> Result<()> {
89 if !self.owns(account) || !self.covers_repo(repo) {
90 return Err(Error::NotFound("repository"));
91 }
92 if !self.has_scope(required) {
93 return Err(Error::Forbidden);
94 }
95 Ok(())
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use crate::git::validate::name;
103
104 fn identity(scopes: Vec<Scope>, repos: Vec<&str>) -> Identity {
105 Identity {
106 account: name("alice").unwrap(),
107 token_id: "t1".into(),
108 scopes,
109 repos: repos.into_iter().map(|r| name(r).unwrap()).collect(),
110 }
111 }
112
113 fn n(value: &str) -> Name {
114 name(value).unwrap()
115 }
116
117 #[test]
118 fn a_write_scoped_token_may_read_and_write_its_own_repos() {
119 let id = identity(vec![Scope::RepoWrite], vec![]);
120 id.require_read(&n("alice"), &n("site")).unwrap();
121 id.require_write(&n("alice"), &n("site")).unwrap();
122 }
123
124 #[test]
125 fn a_read_scoped_token_is_forbidden_from_writing() {
126 let id = identity(vec![Scope::RepoRead], vec![]);
127 id.require_read(&n("alice"), &n("site")).unwrap();
128 assert_eq!(
129 id.require_write(&n("alice"), &n("site"))
130 .unwrap_err()
131 .status(),
132 403
133 );
134 }
135
136 #[test]
137 fn another_accounts_repo_reads_as_absent_rather_than_forbidden() {
138 let id = identity(vec![Scope::Admin], vec![]);
139 assert_eq!(
140 id.require_read(&n("bob"), &n("secret"))
141 .unwrap_err()
142 .status(),
143 404,
144 "403 would enumerate private repositories"
145 );
146 }
147
148 #[test]
149 fn a_case_variant_of_another_account_is_still_another_account() {
150 let id = identity(vec![Scope::Admin], vec![]);
151 // `Alice` folds to `alice`, so this is the caller's own account and allowed;
152 // the guarantee that matters is that it addresses the SAME repository.
153 id.require_read(&n("Alice"), &n("site")).unwrap();
154 assert_eq!(n("Alice"), n("alice"));
155 }
156
157 #[test]
158 fn a_repo_scoped_token_cannot_reach_a_repo_outside_its_list() {
159 let id = identity(vec![Scope::RepoWrite], vec!["site"]);
160 id.require_write(&n("alice"), &n("site")).unwrap();
161 assert_eq!(
162 id.require_write(&n("alice"), &n("other"))
163 .unwrap_err()
164 .status(),
165 404
166 );
167 }
168
169 #[test]
170 fn a_repo_scoped_admin_token_cannot_delete_a_repo_it_cannot_read() {
171 let id = identity(vec![Scope::Admin], vec!["site"]);
172 assert_eq!(
173 id.require_read(&n("alice"), &n("secret"))
174 .unwrap_err()
175 .status(),
176 404
177 );
178 assert_eq!(
179 id.require_repo_admin(&n("alice"), &n("secret"))
180 .unwrap_err()
181 .status(),
182 404,
183 "a token denied read must not be granted destroy"
184 );
185 }
186
187 #[test]
188 fn account_level_admin_requires_an_unconfined_token() {
189 let confined = identity(vec![Scope::Admin], vec!["site"]);
190 assert_eq!(
191 confined
192 .require_account_admin(&n("alice"))
193 .unwrap_err()
194 .status(),
195 403
196 );
197
198 let unconfined = identity(vec![Scope::Admin], vec![]);
199 unconfined.require_account_admin(&n("alice")).unwrap();
200 }
201
202 #[test]
203 fn a_ci_token_cannot_write_through_the_api() {
204 let id = identity(vec![Scope::Ci], vec!["site"]);
205 assert_eq!(
206 id.require_write(&n("alice"), &n("site"))
207 .unwrap_err()
208 .status(),
209 403
210 );
211 }
212}