| 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 | |
| 91 | use crate |
| 92 | |
| 93 | |
| 94 | ; |
| 95 | |
| 96 | use ; |
| 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. |
| 111 | |
| 112 | |
| 113 | |
| 114 | |
| 115 | |
| 116 | |
| 117 | |
| 118 | |
| 119 | |
| 120 | |
| 121 | |
| 122 | |
| 123 | |
| 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. |
| 130 | |
| 131 | |
| 132 | |
| 133 | |
| 134 | |
| 135 | |
| 136 | |
| 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. |
| 143 | pub async |
| 144 | |
| 145 | |
| 146 | |
| 147 | |
| 148 | |
| 149 | |
| 150 | |
| 151 | |
| 152 | |
| 153 | |
| 154 | |
| 155 | |
| 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. |
| 163 | pub async |
| 164 | |
| 165 | |
| 166 | |
| 167 | |
| 168 | |
| 169 | |
| 170 | |
| 171 | |
| 172 | |
| 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. |
| 201 | pub async |
| 202 | |
| 203 | |
| 204 | |
| 205 | |
| 206 | |
| 207 | |
| 208 | |
| 209 | |
| 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. |
| 232 | pub async |
| 233 | |
| 234 | |
| 235 | |
| 236 | |
| 237 | |
| 238 | |
| 239 | |
| 240 | |
| 241 | |
| 242 | |
| 243 | |
| 244 | |
| 245 | |
| 246 | |
| 247 | |
| 248 | |
| 249 | |
| 250 | |
| 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. |
| 259 | pub async |
| 260 | |
| 261 | |
| 262 | |
| 263 | |
| 264 | |
| 265 | |
| 266 | |
| 267 | |
| 268 | |
| 269 | |
| 270 | |
| 271 | |
| 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. |
| 280 | pub async |
| 281 | |
| 282 | |
| 283 | |
| 284 | |
| 285 | |
| 286 | |
| 287 | |
| 288 | |
| 289 | |
| 290 | |
| 291 | |
| 292 | |
| 293 | |
| 294 | |
| 295 | |
| 296 | |
| 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. |
| 313 | pub async |
| 314 | |
| 315 | |
| 316 | |
| 317 | |
| 318 | |
| 319 | |
| 320 | |
| 321 | |
| 322 | |
| 323 | |
| 324 | |
| 325 | |
| 326 | |
| 327 | |
| 328 | |
| 329 | |
| 330 | |
| 331 | |
| 332 | |
| 333 | |
| 334 | |
| 335 | |
| 336 | |
| 337 | |
| 338 |