Make boards have individual access rights #78

Closed
opened 2026-08-14 01:33:30 +00:00 by Grandiras · 2 comments
Owner

Individual users should not see any difference, they should just have access to the boards they're supposed to, without any layer of complexity added. But from the admin stand point, access rights should be separate per board. This is the next step in our effort to turn this into a multi-board version. We will add board creation and management for invited users in the future and also a landing and discovery page, but those are not in the scope of this issue.

Individual users should not see any difference, they should just have access to the boards they're supposed to, without any layer of complexity added. But from the admin stand point, access rights should be separate per board. This is the next step in our effort to turn this into a multi-board version. We will add board creation and management for invited users in the future and also a landing and discovery page, but those are not in the scope of this issue.
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/180); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-78/run-180.md) — updating every ~5 minutes while the task runs.
Collaborator

Pushed. Here's my reply for the issue:


Boards now carry their own access rights. Branch: claude/issue-78 (compare).

The shape

A board is either everyone's or a named few's. Board.Visibility says which, and a new BoardMember join table is the grant list the second one consults — a join table beside Board rather than a column on it, since a board isn't owned.

The switch is there so an empty grant list never has to be interpreted. Read as "everyone", taking the last person off a board would open it to the whole deployment — from the very screen that was removing access. Read as "nobody", every board that predates the feature disappears. One column settles both, and it defaults to Everyone: the migration needs no backfill and nobody's board changes by deploying this.

Nothing new for a member to learn

That was the ask, and it's what the diff does: no lock icon, no greyed-out rows. The nav switcher lists the boards you have, / lands on the first of those, and a board you weren't given is simply not in the menu. The only screens written for this are empty states — because a link can still be pasted into a chat: no such board, there is one and it isn't yours, you have none yet. They're told apart in the DOM by data-outcome, not by their copy.

From the admin side

/admin/access[/{id}] — same shape as the columns and tags pages (board picker on top, bare path is the default board). Two radio options with their consequences spelled out under them, then the member list. Save stays disabled until something actually differs from the database. /admin/boards grew a per-row link that reads as the state ("Shared with everyone" / "Shared with 3 member(s)") and links straight at that page.

Admins see every board, and the page says so beside each admin in the list. An admin can grant themselves any board in one click and the admin pages must be able to edit a board nobody shared with them — hiding one would be theatre. Grants on an open board are kept rather than cleared, so closing a board again is one click and not a new round of invitations.

Where the enforcement actually is

  • One rule, one place: BoardAccess.VisibleTo(viewer) is an Expression, so the same predicate runs in Postgres and compiled in the unit tests. A hand-written second copy for tests would drift, and it'd drift toward a board someone can read that nobody meant them to.
  • Every BoardService read comes in two — one taking a BoardViewer, one ending in ForAdmin — and neither takes an optional viewer. The old one-argument ResolveAsync was removed rather than kept, so a new call site can't silently get the unfiltered one.
  • The two .ics endpoints were the real hole: they're the only paths out of a board that are plain HTTP, so a member could otherwise have walked board ids and read every deadline on the deployment out of the URL bar. Both now build a viewer (cookie principal / the DID behind the feed token) and 404 a board that isn't theirs.
  • Revocation lands on an open page: SetAccessAsync fires BoardsChanged, and both the board and the calendar re-resolve on every reload instead of trusting the answer they got on entry.

Verified

  • dotnet build + all 165 unit tests, Debug and Release (CI's exact commands). 9 of those are new (BoardAccessTests).
  • The migration applied to a real Postgres; schema, FKs and the Everyone default checked by hand, and member-deletion confirmed to cascade its grants away (so removing a member still works).
  • The whole grant/revoke flow driven in Chromium against a locally-run instance: open board visible → restricted → denied state, gone from the nav, .ics 404 → granted → board back, in the switcher, .ics 200. Plus the admin's own view of a board shared with nobody.
  • The existing boards.spec.js (6), board.spec.js, calendar.spec.js, filter.spec.js — 87 passing. Two failures in that run were environmental, not from this change: i18n.spec.js hard-codes localhost:8090 for the locale cookie, and one board.spec.js Escape-key test passed on its own (flaked under a 90-test load against a single local instance).

One thing I couldn't run here: tests/e2e/access.spec.js as committed makes its second member through a PDS signup, and this container has no container engine to bring the stack up. The locally-driven run above is that same test with a pre-seeded member instead — worth one npx playwright test access.spec.js against the real compose stack before merging.

CLAUDE.md and README.md are updated, including the two places that said per-board rights didn't exist yet.


⏱ 34m 23s · 185 turns · tokens: 277.5k in / 102.7k out (+36515.5k cache-read)
📜 Full session transcript
→ Opened PR: #80

Pushed. Here's my reply for the issue: --- Boards now carry their own access rights. Branch: **`claude/issue-78`** ([compare](https://git.grandiras.net/Grandiras/Ponente/compare/main...claude/issue-78)). ## The shape A board is either **everyone's** or **a named few's**. `Board.Visibility` says which, and a new `BoardMember` join table is the grant list the second one consults — a join table beside `Board` rather than a column on it, since a board isn't *owned*. The switch is there so an empty grant list never has to be *interpreted*. Read as "everyone", taking the last person off a board would open it to the whole deployment — from the very screen that was removing access. Read as "nobody", every board that predates the feature disappears. One column settles both, and it defaults to `Everyone`: the migration needs no backfill and **nobody's board changes by deploying this**. ## Nothing new for a member to learn That was the ask, and it's what the diff does: no lock icon, no greyed-out rows. The nav switcher lists the boards you have, `/` lands on the first of *those*, and a board you weren't given is simply not in the menu. The only screens written for this are empty states — because a link can still be pasted into a chat: *no such board*, *there is one and it isn't yours*, *you have none yet*. They're told apart in the DOM by `data-outcome`, not by their copy. ## From the admin side **`/admin/access[/{id}]`** — same shape as the columns and tags pages (board picker on top, bare path is the default board). Two radio options with their consequences spelled out under them, then the member list. Save stays disabled until something actually differs from the database. `/admin/boards` grew a per-row link that reads as the state ("Shared with everyone" / "Shared with 3 member(s)") and links straight at that page. Admins see every board, and the page says so beside each admin in the list. An admin can grant themselves any board in one click and the admin pages must be able to edit a board nobody shared with them — hiding one would be theatre. Grants on an open board are kept rather than cleared, so closing a board again is one click and not a new round of invitations. ## Where the enforcement actually is - One rule, one place: `BoardAccess.VisibleTo(viewer)` is an `Expression`, so the same predicate runs in Postgres and compiled in the unit tests. A hand-written second copy for tests would drift, and it'd drift toward a board someone can read that nobody meant them to. - Every `BoardService` read comes in two — one taking a `BoardViewer`, one ending in `ForAdmin` — and **neither takes an optional viewer**. The old one-argument `ResolveAsync` was removed rather than kept, so a new call site can't silently get the unfiltered one. - **The two `.ics` endpoints were the real hole**: they're the only paths out of a board that are plain HTTP, so a member could otherwise have walked board ids and read every deadline on the deployment out of the URL bar. Both now build a viewer (cookie principal / the DID behind the feed token) and 404 a board that isn't theirs. - Revocation lands on an open page: `SetAccessAsync` fires `BoardsChanged`, and both the board and the calendar re-resolve on every reload instead of trusting the answer they got on entry. ## Verified - `dotnet build` + all **165 unit tests**, Debug and Release (CI's exact commands). 9 of those are new (`BoardAccessTests`). - The migration applied to a real Postgres; schema, FKs and the `Everyone` default checked by hand, and member-deletion confirmed to cascade its grants away (so removing a member still works). - The **whole grant/revoke flow driven in Chromium** against a locally-run instance: open board visible → restricted → denied state, gone from the nav, `.ics` 404 → granted → board back, in the switcher, `.ics` 200. Plus the admin's own view of a board shared with nobody. - The existing `boards.spec.js` (6), `board.spec.js`, `calendar.spec.js`, `filter.spec.js` — 87 passing. Two failures in that run were environmental, not from this change: `i18n.spec.js` hard-codes `localhost:8090` for the locale cookie, and one `board.spec.js` Escape-key test passed on its own (flaked under a 90-test load against a single local instance). One thing I couldn't run here: `tests/e2e/access.spec.js` as committed makes its second member through a PDS signup, and this container has no container engine to bring the stack up. The locally-driven run above is that same test with a pre-seeded member instead — worth one `npx playwright test access.spec.js` against the real compose stack before merging. `CLAUDE.md` and `README.md` are updated, including the two places that said per-board rights didn't exist yet. --- ⏱ 34m 23s · 185 turns · tokens: 277.5k in / 102.7k out (+36515.5k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-78/run-180.md) → Opened PR: https://git.grandiras.net/Grandiras/Ponente/pulls/80
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#78
No description provided.