Roles: per-board admins, and a super-admin from the config #192

Closed
opened 2026-09-18 17:12:51 +00:00 by Grandiras · 2 comments
Owner

Part of #190, which has the role table this implements. Read docs/boards-and-permissions.md and docs/auth.md first — this issue deliberately reverses "a board is not owned" and "admins see every board", and both pages have to say the new thing, with the new reason, in the same pull request.

No new screens. After this, the app looks the same to everyone and the rights underneath have moved.

The model

  • BoardMember gets a role — member or admin. A role on the grant row rather than an OwnerDid on Board: several admins are possible, handing a board over is one row, and it is the shape a Spaces member list will have.
  • "Is an admin" becomes a fact about a viewer on a board. BoardViewer(Did, IsAdmin) is built once per circuit from the allowlist today; that IsAdmin has to come from the board being resolved instead. BoardAccess.VisibleTo loses its admin bypass, and the two admin branches in CardPermissions read the board role. Keep the property that a call site cannot forget who is asking.
  • One place for who may run a board — rename, columns, tags, access, delete — in the shape of CardPermissions. BoardService, ColumnService and TagService mutators take the viewer and go through it; today they take none because only a page behind the Admin policy can reach them, and that stops being true in the next step.
  • The super-admin is Allowlist:Admins[], read at runtime. Today DatabaseInitializer only seeds those DIDs on first run and Members.Role is the truth afterwards. The Admin policy becomes the super-admin policy and gates the invite list: /admin/members, the requests panel, the invites panel. A super-admin is always allowlisted. deploy/README.md should say that removing a DID from the env now demotes on restart.
  • A board always has at least one admin. Removing or demoting the last one is refused, in the service.
  • Board names stop being unique across the deployment. DuplicateBoardNameException would tell a member that a private board called "Redundancies" exists.

Migrating

The rule from #190: nobody's access changes on deploy day, and the previous release can still run against this database.

  • Every Members row with the admin role becomes a board admin on every existing board. None of those columns is encrypted, so this is SQL inside the migration, not a startup pass. If a deployment somehow has no admin row, the configured super-admin DIDs take their place, so no board is left without one.
  • Existing BoardMember rows default to member. Visibility is untouched: an Everyone board stays everyone's.
  • Members.Role stays in the schema for this release, unread, and is dropped in a later one. Dropping it now means a rollback starts a build that selects a column that is gone.
  • An admin who was promoted in the UI and is not in the config loses the members page. That is the decision, not a side effect — it gets a changelog line in the app's words.

Tests

access.spec.js and permissions.spec.js carry most of it: a board admin who is not the super-admin can do on their board what an admin could; a super-admin not on a restricted board cannot open it, and it is not in their switcher; the last admin cannot be removed. BoardAccessTests for the expression, plus a unit test for the new permissions class. The migration's backfill deserves a test of its own if the suite can reach it.

Part of #190, which has the role table this implements. Read `docs/boards-and-permissions.md` and `docs/auth.md` first — this issue deliberately reverses "a board is not owned" and "admins see every board", and both pages have to say the new thing, with the new reason, in the same pull request. No new screens. After this, the app looks the same to everyone and the rights underneath have moved. ## The model - **`BoardMember` gets a role** — member or admin. A role on the grant row rather than an `OwnerDid` on `Board`: several admins are possible, handing a board over is one row, and it is the shape a Spaces member list will have. - **"Is an admin" becomes a fact about a viewer *on a board*.** `BoardViewer(Did, IsAdmin)` is built once per circuit from the allowlist today; that `IsAdmin` has to come from the board being resolved instead. `BoardAccess.VisibleTo` loses its admin bypass, and the two admin branches in `CardPermissions` read the board role. Keep the property that a call site cannot forget who is asking. - **One place for who may run a board** — rename, columns, tags, access, delete — in the shape of `CardPermissions`. `BoardService`, `ColumnService` and `TagService` mutators take the viewer and go through it; today they take none because only a page behind the `Admin` policy can reach them, and that stops being true in the next step. - **The super-admin is `Allowlist:Admins[]`, read at runtime.** Today `DatabaseInitializer` only seeds those DIDs on first run and `Members.Role` is the truth afterwards. The `Admin` policy becomes the super-admin policy and gates the invite list: `/admin/members`, the requests panel, the invites panel. A super-admin is always allowlisted. `deploy/README.md` should say that removing a DID from the env now demotes on restart. - **A board always has at least one admin.** Removing or demoting the last one is refused, in the service. - **Board names stop being unique across the deployment.** `DuplicateBoardNameException` would tell a member that a private board called "Redundancies" exists. ## Migrating The rule from #190: nobody's access changes on deploy day, and the previous release can still run against this database. - Every `Members` row with the admin role becomes a board admin on **every existing board**. None of those columns is encrypted, so this is SQL inside the migration, not a startup pass. If a deployment somehow has no admin row, the configured super-admin DIDs take their place, so no board is left without one. - Existing `BoardMember` rows default to member. `Visibility` is untouched: an `Everyone` board stays everyone's. - **`Members.Role` stays in the schema for this release**, unread, and is dropped in a later one. Dropping it now means a rollback starts a build that selects a column that is gone. - An admin who was promoted in the UI and is not in the config loses the members page. That is the decision, not a side effect — it gets a changelog line in the app's words. ## Tests `access.spec.js` and `permissions.spec.js` carry most of it: a board admin who is not the super-admin can do on their board what an admin could; a super-admin not on a restricted board cannot open it, and it is not in their switcher; the last admin cannot be removed. `BoardAccessTests` for the expression, plus a unit test for the new permissions class. The migration's backfill deserves a test of its own if the suite can reach it.
Collaborator

🤖 On it — the reply will link the full session transcript.

🤖 On it — the reply will link the full session transcript. <!-- opencode-run:task -->
Collaborator

Steps 1–2 verified (the step-3 "failure" was my script checking an already-on toggle — correctly not dirty). Let me check whether the full e2e stack is runnable here:

The run reported an error after replying: This operation was aborted

→ Opened PR: #201


⏱ 45m 2s · 215 steps · $1.5586 · tokens: 1742.4k in / 92.3k out (+41702.9k cache-read) · Go: 5h 41% · wk 41% · mo 22%

📜 Full session transcript

Steps 1–2 verified (the step-3 "failure" was my script checking an already-on toggle — correctly not dirty). Let me check whether the full e2e stack is runnable here: _The run reported an error after replying: This operation was aborted_ → Opened PR: https://git.grandiras.net/Grandiras/Ponente/pulls/201 --- ⏱ 45m 2s · 215 steps · $1.5586 · tokens: 1742.4k in / 92.3k out (+41702.9k cache-read) · Go: 5h 41% · wk 41% · mo 22% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/issue-192/run-2026-09-18T18-45-49-937Z.md) <!-- opencode-session:ses_f4a292bb2ffehIGfkkS5I0UeTW -->
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/Ponente#192
No description provided.