steid

@jamesgill /

Rust15.5 KBCode·Blame·History·Raw
1//! Who may do what: every authorization predicate in the application layer.
2//!
3//! One answer per question, in one place. A predicate copied into a second use case
4//! is a predicate that will eventually disagree with itself — and an authorization
5//! check that disagrees with itself fails open somewhere.
6//!
7//! **Two kinds of caller, two predicates.** Everything here answers a question about an
8//! [`Actor`] — a person with a session. The other kind of caller is a *machine*: a
9//! control plane holding `STEID_OPERATOR_TOKEN`, which has no session, no user and no
10//! `Actor` to be. That one is authenticated by `web::operator::operator_authorized`, a
11//! constant-time bearer-token comparison living in the web layer because a transport
12//! credential is a transport concern, and it grants exactly the operator routes —
13//! never a repository, never a user's data. So "one place decides who may do what"
14//! reads: this module decides for every actor, `operator_authorized` for the machine,
15//! and nothing else decides at all.
16//!
17//! The predicates come in two layers. [`is_org_owner`], [`is_org_member`] and
18//! [`instance_owner`] answer a question about an actor and a subject; the
19//! `*_repo` resolvers answer "which repository, if any, may this actor do X to",
20//! and are what a use case actually starts with:
21//!
22//! | Resolver | Reading | Refusal |
23//! |---|---|---|
24//! | [`visible_repo`] | visibility, plus membership for a private one | `Ok(None)` |
25//! | [`readable_repo`] | the same, plus whether the actor owns it | `Ok(None)` |
26//! | [`writable_repo`] | owner only | `NotFound` unseen, `Forbidden` seen |
27//! | [`changeable_repo`] | owner only, returning the stored row | `NotFound` unseen, `Forbidden` seen |
28//!
29//! **[`visible_repo`] is the bottom of that stack and the only body the visibility
30//! rule is written in.** The other three layer on it, so a change to what "may see"
31//! means is one edit, not four — which is the whole reason the copy that lived in
32//! `repo.rs` had to come back here.
33//!
34//! `writable_repo` and `changeable_repo` enforce the same rule from opposite ends —
35//! one layered on `readable_repo`, one on `visible_repo` — and are kept separate
36//! because their callers differ in what they need back. See the individual docs.
37//!
38//! # Milestone 7, in advance
39//!
40//! **Multi-user changes this file, not the use cases.** "Owner-only" becomes a role
41//! with graded permissions — a collaborator who may comment on an issue but not merge
42//! a pull request — which is a change to these predicates' bodies and, where a
43//! permission has to be named, to their signatures: roughly twenty lines here rather
44//! than thirty use cases. Keeping every use case's first line a call into this module
45//! is what buys that, so a use case that decides authorization inline is not a
46//! shortcut, it is the thing that makes the milestone expensive. What follows is the
47//! shape, written down while it is still cheap to be wrong about it.
48//!
49//! ## Which predicates change
50//!
51//! | Predicate | Sites | Rule today | Milestone 7 |
52//! |---|---|---|---|
53//! | [`visible_repo`] / [`readable_repo`] | 7 read use cases + 3 resolvers | public, or any member | **unchanged** |
54//! | [`writable_repo`] | 14, in `issue`, `label`, `pull_request` | owner only | **gains a permission argument** — comment, open an issue, apply a label yes; merge no |
55//! | [`changeable_repo`] | 13, in `repo`, `webhook`, `import` | owner only | **unchanged** — repository administration is not graded |
56//! | [`is_org_owner`], called directly | `repo::create_repo`, `push`, `git::serve_git`, `import`, `profile` | owner only | only `serve_git`'s changes; a collaborator needs push, and the other four stay owner-only |
57//! | [`instance_owner`] | backup, settings | the sole user | **an instance-level role**, which does not exist yet |
58//!
59//! So exactly one signature moves: `writable_repo` takes the permission being
60//! exercised. That argument is the whole design — a caller that cannot name what it is
61//! about to do cannot be graded, and every one of the fourteen can.
62//!
63//! ## What a `Role::Member` may do today
64//!
65//! Read a private repository of an organisation they belong to, and **nothing else**:
66//! no comment, no issue, no label, no pull request, no push. Nothing fails open. The
67//! third role is therefore a feature to add, not a hole to close — `memberships`
68//! already carries `(org_id, user_id, role)` with a unique pair, so a third [`Role`]
69//! variant is a value change rather than a migration.
70//!
71//! **What is not decided**: whether a collaborator on one repository is a membership of
72//! a personal organisation or a new per-repository relation. That is an ADR, because it
73//! decides a table.
74//!
75//! ## What fails closed the moment a second user exists
76//!
77//! Deliberately, all three — but they are the milestone's first visible breakage, so
78//! they are listed rather than discovered:
79//!
80//! - [`instance_owner`] answers `false` for **everybody**, because `sole_user` returns
81//! `None` for two rows. The settings page's Backup section disappears and
82//! `/{handle}/settings/backup.tar.gz` 404s for the owner too. `steid backup` on the
83//! box still works, so it is recoverable — which is why this is the safe direction to
84//! fail in, and why an instance-level role is milestone 7's first piece.
85//! - [`sole_owner_handle`](super::claim::sole_owner_handle) answers `None`, so `/`
86//! stops redirecting to the owner's profile and serves the generic landing. Safe, and
87//! plain enough to need a real page.
88//! - A backup is every row and every private repository. **Do not loosen
89//! [`instance_owner`] to "any owner of any org"** to make the first item go away.
90
91use crate::domain::{
92 Actor, DomainError, OrgName, Organization, RepoName, Repository, Role,
93 repository::{MembershipRepository, OrgRepository, RepoRepository, UserRepository},
94};
95
96use super::error::{Error, Result};
97
98/// The three stores a repository is reached through: the organisation by handle, the
99/// actor's membership in it, and the repository by name.
100///
101/// One value rather than three parameters. Almost every use case in the layer needs
102/// exactly these three and needs them together — a handle and a name mean nothing
103/// without all of them — and threading them separately cost three parameters on
104/// fifty-six signatures, which is most of why `too_many_arguments` had to be silenced
105/// as often as it was. Ports that only some use cases need (issues, labels, pulls,
106/// users, git) stay separate parameters, because a bundle that grows to hold everything
107/// is a service locator.
108///
109/// Borrowed, never owning: the adapters are cheap handles onto one pool, and the
110/// bundle is built at the call site and passed by reference.
111pub struct Stores<'a, O: OrgRepository, M: MembershipRepository, R: RepoRepository> {
112 pub orgs: &'a O,
113 pub memberships: &'a M,
114 pub repos: &'a R,
115}
116
117impl<'a, O: OrgRepository, M: MembershipRepository, R: RepoRepository> Stores<'a, O, M, R> {
118 /// Bundles three borrowed stores. Positional, in the order the resolvers read them.
119 pub fn new(orgs: &'a O, memberships: &'a M, repos: &'a R) -> Self {
120 Self {
121 orgs,
122 memberships,
123 repos,
124 }
125 }
126}
127
128// `derive` would demand `O: Copy`, which an adapter is not; the bundle is three
129// references and copying it is copying those.
130impl<O: OrgRepository, M: MembershipRepository, R: RepoRepository> Clone for Stores<'_, O, M, R> {
131 fn clone(&self) -> Self {
132 *self
133 }
134}
135
136impl<O: OrgRepository, M: MembershipRepository, R: RepoRepository> Copy for Stores<'_, O, M, R> {}
137
138/// Whether the actor owns this organisation.
139///
140/// Owner is strictly stronger than membership: a member may read what they can see,
141/// but editing the profile, creating a repository, and pushing are all owner-only.
142/// "Signed in" quietly becoming "allowed" is the usual way this goes wrong.
143pub(crate) async fn is_org_owner(
144 org: &Organization,
145 actor: &Actor,
146 memberships: &impl MembershipRepository,
147) -> Result<bool> {
148 let Some(user_id) = actor.user_id() else {
149 return Ok(false);
150 };
151
152 Ok(memberships
153 .find(&org.id, user_id)
154 .await?
155 .is_some_and(|membership| membership.role == Role::Owner))
156}
157
158/// Whether the actor belongs to this organisation at all, in any role.
159///
160/// The weaker predicate: it gates *seeing* a private repository, where
161/// [`is_org_owner`] gates changing things. Keeping them separate is what stops a
162/// read rule and a write rule from being accidentally satisfied by the same check.
163pub(crate) async fn is_org_member(
164 org: &Organization,
165 actor: &Actor,
166 memberships: &impl MembershipRepository,
167) -> Result<bool> {
168 let Some(user_id) = actor.user_id() else {
169 return Ok(false);
170 };
171
172 Ok(memberships.find(&org.id, user_id).await?.is_some())
173}
174
175/// Whether this actor owns the **instance** — not an organisation in it.
176///
177/// The strongest predicate here, and the one with no repository in it at all: it gates
178/// the whole database rather than one org's rows. A backup is every user, every private
179/// repository and every token, so "owns some organisation" is the wrong test and would
180/// hand the instance to anybody who made one.
181///
182/// **A named predicate rather than a comparison against `sole_user`.** Today it means
183/// "the sole user", which is what "the owner" means while Steid has exactly one — the
184/// same definition [`sole_owner_handle`](super::claim::sole_owner_handle) uses for the
185/// front door, and for the same reason: nothing else is well defined. Tomorrow it means
186/// "a user holding the instance-admin role", and the point of the name is that milestone
187/// 7 changes this body and nothing else. Nothing outside this module compares against
188/// `sole_user` to decide an authorization question; a page asks
189/// [`context::instance_owner`](crate::infrastructure::web::context::instance_owner),
190/// which asks this.
191///
192/// Not to be confused with `web::operator::operator_authorized`, the other
193/// instance-level check: that one authenticates a **machine** holding
194/// `STEID_OPERATOR_TOKEN` and has no `Actor` at all. Two kinds of caller, two
195/// predicates — see the module docs.
196///
197/// **Answers `false` for everybody once a second user exists**, deliberately. The right
198/// rule then is an instance-level role that does not exist yet, and a backup surface
199/// that refuses everyone is recoverable — the command line still works — where one that
200/// guesses hands the whole database to whoever registered second.
201pub async fn instance_owner(actor: &Actor, users: &impl UserRepository) -> Result<bool> {
202 let Some(acting) = actor.user_id() else {
203 return Ok(false);
204 };
205
206 Ok(users
207 .sole_user()
208 .await?
209 .is_some_and(|owner| &owner.id == acting))
210}
211
212/// Resolves a handle and name into a repository the actor may see, with its organisation.
213///
214/// **The one visibility rule, and the only place it is written down.** Every other
215/// resolver here layers on it: [`readable_repo`] adds "and do they own it",
216/// [`writable_repo`] and [`changeable_repo`] add "and only if they do". The read use
217/// cases — browse, blame, search, commit, archive, feed, summary — call it directly,
218/// because they need the authorization and nothing else: asking for a read model
219/// instead would cost a query per raw blob for a `RepoView` they discard.
220///
221/// `Ok(None)` covers **both** "no such repository" and "not allowed to see it", and the
222/// caller must render them identically. Distinguishing them would confirm that a private
223/// repository exists and reveal its name, which is the thing being protected — a private
224/// repo has to be absent, not merely unlinked.
225///
226/// Private repositories are visible to any member of the owning organisation, not only
227/// its owner: seeing is weaker than changing.
228///
229/// Lived in `repo.rs` until 2026-09-10, where it was a third verbatim copy of this body
230/// with its arguments in a different order. Milestone 7's third role has to land in one
231/// body, not three.
232pub(crate) async fn visible_repo(
233 actor: &Actor,
234 handle: &OrgName,
235 name: &RepoName,
236 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
237) -> Result<Option<(Organization, Repository)>> {
238 let Some(org) = stores.orgs.find_by_name(handle).await? else {
239 return Ok(None);
240 };
241
242 let Some(repo) = stores.repos.find_by_org_and_name(&org.id, name).await? else {
243 return Ok(None);
244 };
245
246 if !repo.visibility.is_public() && !is_org_member(&org, actor, stores.memberships).await? {
247 return Ok(None);
248 }
249
250 Ok(Some((org, repo)))
251}
252
253/// Resolves a repository the actor may read, and whether they own it.
254///
255/// [`visible_repo`] plus the ownership flag, which is what a page's frame needs and a
256/// bare visibility check does not. The same rule [`view_repo`](super::view_repo)
257/// applies, but returning the stored [`Repository`] rather than a view — issues hang off
258/// its id, which a `RepoView` does not carry.
259pub(crate) async fn readable_repo(
260 actor: &Actor,
261 handle: &OrgName,
262 name: &RepoName,
263 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
264) -> Result<Option<(Repository, bool)>> {
265 let Some((org, repo)) = visible_repo(actor, handle, name, stores).await? else {
266 return Ok(None);
267 };
268
269 Ok(Some((
270 repo,
271 is_org_owner(&org, actor, stores.memberships).await?,
272 )))
273}
274
275/// Resolves a repository the actor may **write to** — the owner, and nobody else.
276///
277/// Not found for a repository they cannot see, forbidden for one they can. A member's
278/// read access is not permission to add to someone's issue tracker, the same way it is
279/// not permission to create a repository.
280pub(crate) async fn writable_repo(
281 actor: &Actor,
282 handle: &OrgName,
283 name: &RepoName,
284 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
285) -> Result<Repository> {
286 let Some((repo, is_owner)) = readable_repo(actor, handle, name, stores).await? else {
287 return Err(Error::Domain(DomainError::NotFound {
288 entity: "repository",
289 }));
290 };
291
292 if !is_owner {
293 return Err(Error::Domain(DomainError::Forbidden));
294 }
295
296 Ok(repo)
297}
298
299/// Resolves a repository the actor is allowed to **change**.
300///
301/// Two different answers, on purpose, and the split matters:
302///
303/// - A repository the actor may not *see* is [`DomainError::NotFound`], exactly as
304/// [`view_repo`](super::view_repo) returns `None` for it. Saying "forbidden" instead
305/// would confirm that a private repository by that name exists, which is the thing
306/// being protected.
307/// - A repository the actor *can* see but does not own is [`DomainError::Forbidden`],
308/// matching [`create_repo`](super::create_repo): the resource is public anyway, so
309/// pretending it is missing would be theatre.
310///
311/// A caller that wants to collapse both into a 404 — the settings page does — can; a
312/// caller that wants to explain the difference has the information to.
313pub(crate) async fn changeable_repo(
314 actor: &Actor,
315 handle: &OrgName,
316 name: &RepoName,
317 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
318) -> Result<Repository> {
319 let missing = || {
320 Error::Domain(DomainError::NotFound {
321 entity: "repository",
322 })
323 };
324
325 let Some((org, repo)) = visible_repo(actor, handle, name, stores).await? else {
326 return Err(missing());
327 };
328
329 if !is_org_owner(&org, actor, stores.memberships).await? {
330 return Err(DomainError::Forbidden.into());
331 }
332
333 Ok(repo)
334}
335
336#[cfg(test)]
337#[path = "access_tests.rs"]
338mod tests;