| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package policy |
| 4 | |
| 5 | // Actor is the authenticated identity asking for a decision. The web |
| 6 | // layer constructs one from middleware.CurrentUserFromContext + a |
| 7 | // suspended/admin check. SSH and HTTP git transports build their own |
| 8 | // from the resolved auth principal. |
| 9 | // |
| 10 | // An anonymous request has UserID == 0; IsAnonymous == true. Convention |
| 11 | // is that callers fill IsAnonymous explicitly even when UserID == 0 |
| 12 | // implies it — duplication is cheap and keeps the boolean visible at |
| 13 | // every call site. |
| 14 | type Actor struct { |
| 15 | UserID int64 |
| 16 | Username string |
| 17 | IsAnonymous bool |
| 18 | IsSuspended bool |
| 19 | IsSiteAdmin bool |
| 20 | // Impersonating reports whether this actor was constructed from |
| 21 | // an admin viewing-as another user. ImpersonateWriteOK enables |
| 22 | // the admin's writes during the impersonation; without it, every |
| 23 | // write action gets denied (the canonical foot-gun guard). |
| 24 | Impersonating bool |
| 25 | ImpersonateWriteOK bool |
| 26 | } |
| 27 | |
| 28 | // AnonymousActor returns the canonical anonymous Actor. Use in tests |
| 29 | // and at unauthenticated entrypoints. |
| 30 | func AnonymousActor() Actor { |
| 31 | return Actor{IsAnonymous: true} |
| 32 | } |
| 33 | |
| 34 | // UserActor wraps a logged-in user. Suspension and site-admin flags |
| 35 | // must be loaded from the DB by the caller — the policy package does |
| 36 | // not query users on its own to keep the decision pure. |
| 37 | // |
| 38 | // New web-layer code should prefer UserActorFromCurrentUser, which |
| 39 | // also propagates the impersonation pair so admin viewing-as flows |
| 40 | // don't silently leak write permission. UserActor is preserved for |
| 41 | // callers that don't have a CurrentUser handy (SSH/HTTP git, worker |
| 42 | // jobs, tests). |
| 43 | func UserActor(userID int64, username string, suspended, siteAdmin bool) Actor { |
| 44 | return Actor{ |
| 45 | UserID: userID, |
| 46 | Username: username, |
| 47 | IsSuspended: suspended, |
| 48 | IsSiteAdmin: siteAdmin, |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | // CurrentUserView is the minimal view of the request-bound user that |
| 53 | // UserActorFromCurrentUser needs. The web's middleware.CurrentUser |
| 54 | // satisfies this implicitly — keeping the type local avoids a |
| 55 | // policy → middleware import cycle. |
| 56 | type CurrentUserView struct { |
| 57 | ID int64 |
| 58 | Username string |
| 59 | IsSuspended bool |
| 60 | IsSiteAdmin bool |
| 61 | ImpersonatedUserID int64 |
| 62 | RealActorID int64 |
| 63 | ImpersonateWriteOK bool |
| 64 | } |
| 65 | |
| 66 | // UserActorFromCurrentUser is the canonical web-layer constructor. |
| 67 | // Beyond UserActor, it propagates the impersonation pair so the |
| 68 | // "read-only by default" promise on policy.go's impersonation gate |
| 69 | // is actually enforced — and so audit rows can be tagged with the |
| 70 | // real actor ID when impersonating. |
| 71 | // |
| 72 | // Convention: every handler that can run for an impersonating admin |
| 73 | // should construct the actor through this constructor. Any callsite |
| 74 | // still on plain UserActor with IsSiteAdmin=false hardcoded is a |
| 75 | // silent permission leak (audit 2026-05-10 C1/C2). |
| 76 | func UserActorFromCurrentUser(v CurrentUserView) Actor { |
| 77 | return Actor{ |
| 78 | UserID: v.ID, |
| 79 | Username: v.Username, |
| 80 | IsSuspended: v.IsSuspended, |
| 81 | IsSiteAdmin: v.IsSiteAdmin, |
| 82 | Impersonating: v.ImpersonatedUserID != 0, |
| 83 | ImpersonateWriteOK: v.ImpersonateWriteOK, |
| 84 | } |
| 85 | } |
| 86 |