mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-28 22:25:51 -05:00
2.7 KiB
2.7 KiB
Permissions
How authorization works, and where a new check should go. Two axes exist:
- Global roles (
Roleinapp/modules/permissions/types.ts): "may this user do this kind of thing at all" — gated withrequireRole()on the server anduseHasRole()in components. - Per-object permissions: "may this user act on this entity" — the subject of this doc.
Per-object permissions
An entity that can be acted on carries a server-computed permissions object: a record from permission name to the list of user ids holding it.
// in the Repository read function
return {
...row,
permissions: {
EDIT: [row.authorId],
DELETE: startTimeIsInTheFuture ? [row.authorId] : [],
},
};
Rules:
- Permissions objects are built in Repositories, at read time, next to the query that loads the entity. They serialize to the client with the rest of the loader data, so server and client check the same values.
- Checked only via the central helpers:
requirePermission(entity, "EDIT")in actions/loaders (throws 403),useHasPermission(entity, "EDIT")in components, and the purehasPermission(entity, "EDIT", user)where a hook doesn't fit (non-throwing server checks, checks inside a render loop). - Time and state conditions are baked into the list at read time. A calendar event that has started gets
DELETE: [], a scrim without an accepted request getsMANAGE_TRACKING: []. Don't add predicates or re-check conditions at the call site. - No feature-level admin checks for object authorization. The admin bypass lives in
hasPermission()alone (production only, so tests and development exercise the real lists). Feature code callingisAdmin()/useHasRole("ADMIN")to authorize an action on an object is a bug.
What stays outside the system
Two documented boundaries:
- Non-enumerable grants: permissions held by an open class of users can't be expressed as an id list. Example: any high-enough plus tier member may comment on a suggestion (
canAddCommentToSuggestion*in plus-suggestions). These stay as plain helper functions. - Derived-state checks: a grant that depends on state the repository cannot see at read time. In the
Tournamentclass,canFinalize,canCheckInToBracket,canAddNewSubPostand friends all read bracket state that only exists once the bracket engine has run, so they stay methods. Their authorization half still goes through the permissions object:hasPermission(this.ctx, "ORGANIZE", user) && everyBracketOver, never a re-implemented organizer check.
Also not authorization: membership/domain logic like isTeamMember, isTeamFull, resolveNewOwner — these describe domain facts, not grants, and remain ordinary helpers.