No reviewers
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
qwc-open/asiakirjat!154
Loading…
Reference in a new issue
No description provided.
Delete branch "feature/access-redesign"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #150 and #151.
Four mechanisms granted access, with three copies of one shape.
global_accessandaccess_list_membershave identical columns —(subject_type, subject_identifier, role)— andauth_group_mappingsis the same again with the target inlined and groups only. Three parallel resolved-grant tables sat behind them:global_access_grants,access_list_grants,project_access. A project's real permissions were spread across all four, which is why the visibility dropdown stopped meaning anything.They collapse to one noun and one edge:
The whole policy is one sentence: your role on a project is the strongest role any grant gives you, on the project or on its organization.
Design decisions worth your eye
access_list_members.roleforced a list to carry one role everywhere, so "engineering edits A but only reads B" needed two lists. Now it is two rows.access_grantskeeps four real FK columns with CHECK constraints rather than a polymorphic(subject_type, subject_id)pair, so a grant dies with what it points at. An orphan row that later matched a reused id would grant access to a different project./admin,/searchwould have been the real collision, not "No Org").The safety net
internal/access/migration_equivalence_test.gobuilds an installation using all four old mechanisms at once, snapshots what the old checker allowed for every (user, project) pair, runs the migration, and asserts the new resolver allows precisely the same. It caught two real bugs and pinned one intended change.The one intended difference, signed off: a project creator could previously manage a project they could not view or upload to (PR #118 decided that from
created_by). Ownership is an admin grant now, and admin outranks editor outranks viewer, so creators gain view and upload on their own projects. It is listed explicitly inintendedChanges.Reviewing it
Commit by commit, in order — each stands on its own:
MigrateAccessModel+Resolver+ the equivalence testconfig.yamldeclares the modelBefore you run it at work
Since you are testing with real LDAP and exported production data on Kubernetes:
app_meta.access_model_migratedin one transaction. Testing against a copy of production is exactly right; re-running needs a fresh copy or that row cleared.access model migrated groups=N grants=N resolved_memberships=N ambiguous_grants_pinned=N. The last counter is the one to look at: it counts syncedproject_accessrows the migration could not trace back to a single group mapping, which become direct user grants on that project only — exactly the access held, never more. Each one is logged with its project and user.access_group_resolved, not by reading the tables straight after startup.TestConcurrentMigrationRunsOncecovers it. Worth knowing it was a real crash-loop before that fix.access.privatein config is no longer applied and warns loudly at startup;auth.*.project_groupsstill is, translated. If your work config uses either, read that warning.Deliberately not in this PR
Dropping
visibility,access_lists,global_access,auth_group_mappings,project_accessandaccess.Checker. That data is the only way back if production goes wrong, and the equivalence test needs both sides to keep proving anything. Per your call: at 1.0.Assisted-by: Claude Opus 5
The migration's only job is to preserve exactly who can reach what, and both failure directions are silent: a leak looks like nothing, and a lockout looks like the app is broken. So this lands with its safety net. MigrateAccessModel folds four mechanisms into groups and grants, in one transaction, once — guarded by a marker in app_meta, because re-running it every startup would recreate grants an admin had since revoked: - access lists become groups, split per member role where a list carried more than one, since a group cannot hold two roles for one project; - global_access becomes a "Private Access" group, granted on each project that was private. Not on the default org, which would have been tidier: an org grant cascades to every project in it, including the ones whose visibility was 'custom' precisely to keep those people out; - each auth group mapping becomes a group holding that one provider group, granted on the mapped project — keyed by subject, so an LDAP and an OAuth2 group sharing a name are not merged into one set of people; - project_access 'manual' rows become direct user grants; synced rows are traced back to the mapping that wrote them and become resolved group memberships, so the sync can still revoke them later. Where several mappings could have written a row, the fact is not recoverable, and it becomes a direct grant on that project only — exactly the access held, never more — and is logged. Resolver answers the same questions against the new model. The whole policy is one sentence: your role on a project is the strongest role any grant gives you, on the project or on its org. Two instance-level rules are preserved verbatim rather than quietly improved — admin is admin everywhere, and the M-2 global-editor upload asymmetry stays. TestMigrationPreservesAccessExactly enumerates every (user, project) pair against an installation using all four old mechanisms at once, snapshots what the old checker allowed, migrates, and asserts the resolver allows precisely the same. It found two things: - projects created after the schema migration but before the handler rewrite lost their public exposure, because Create defaulted to 'granted' instead of deriving from visibility; - one intended widening, now listed explicitly in the test: a project creator could previously manage a project they could not read or upload to. Ownership is an admin grant now, and admin outranks editor outranks viewer. Assisted-by: Claude Opus 5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marcel M. Otte <marcel.otte@mmo.to>The checker flip and the admin UI have to land together. Flipping alone would leave the admin pages writing to tables the resolver no longer reads — a form that saves somewhere nothing consults, which is the exact failure this redesign exists to end. The seam turned out to be three wrapper functions and six CanManage calls. Everything else follows from one sentence: your role on a project is the strongest role any grant gives you, on the project or on its org. Admin surface: - Access Groups: name a set of people (users, LDAP groups, OAuth2 groups, or a mix), rename it, edit it. Membership carries no role. - Organizations: every project belongs to one, and a role granted there reaches every project in it. - Projects and orgs both get the same Access table: grant to a group or to a single user, with a role. Granting the same subject twice changes its role rather than adding a row; a revoke that matches nothing says so. - The project form asks how far the project reaches beyond its grants — public, any signed-in user, or granted only — replacing four visibility values whose differences were all about grants. "Any signed-in user" is new; the old model could not say it. The admin nav moves into a partial. It was copied into six templates, so adding a section meant editing six files and noticing all six; the superseded mechanisms stay linked, dimmed, until their tables go. projects.Service.Create now grants the creator admin of their project, so ownership is data rather than a created_by branch in the checker. Two bugs the new tests caught, both real beyond the tests: - ProjectStore.Create resolved org_id with COALESCE inside the INSERT, so the caller's struct kept a nil OrgID and every org-scoped grant was invisible to an access check made on it. - The edit form posts exposure, not visibility, and the update handler rejected the missing legacy field with a 400. Docs: how-to/manage-access.md describes the model end to end; the two guides it supersedes now say so and point at it. Assisted-by: Claude Opus 5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marcel M. Otte <marcel.otte@mmo.to>The config keys that fed the old tables were the last thing on this branch still writing where nothing reads. They now feed groups and grants. access.groups and access.grants mirror the model exactly: a named set of people, and a role for a group or user on an org or a project. What the file declares, the file owns — rows written from config carry source='config' and are reconciled against it on every startup, so deleting an entry revokes it. Rows added in the admin UI carry source='manual' and are never touched, so a provisioned baseline and hand-made exceptions coexist. That ownership split is why access_group_members gains a source column. It goes into migration 016 rather than a new one: the branch is unreleased, so no database has it yet. Additive-only membership would have meant deleting a line from config silently changing nothing, which is the bug class this redesign is about. The two retired keys are handled differently, on purpose: - auth.ldap.project_groups and auth.oauth2.project_groups translate cleanly — one auth group, one project, one role — so they are still applied, into the same group names MigrateAccessModel chose, and warn at startup. An operator who upgraded and one who started fresh get the same result. - access.private is NOT applied. It granted access to every project whose visibility was "private", and that visibility no longer exists; there is no scope it maps onto without either widening access to projects that were deliberately narrower, or inventing a per-project list the file never asked for. Existing installations had it translated once already, at the database level. It warns and says where its members went. Bad entries are logged and skipped rather than fatal: one typo in a project slug should not stop the server. The removal pass keys on what actually landed, not on what was asked for, so a typo cannot masquerade as a declaration and survive. Assisted-by: Claude Opus 5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marcel M. Otte <marcel.otte@mmo.to>Four things, all the same complaint: the access model is in place but the pages people actually use had not caught up with it. The front page gave no sign which organization a project belongs to. It now groups them, default organization first — that is where everything lives on an installation that has not started using organizations, so burying it under an alphabetically earlier one would be strange. Headings and the organization filter only appear once there is more than one, since a heading over the only group is noise on every page load. Each card names its organization, which still matters when a text filter cuts across groups. The box on that page said "Search projects" while the actual full-text search sits at the top of the same screen. It filters what is already listed, so it now says so. The organization filter beside it is an editable combobox: a partly typed name narrows rather than matching nothing, and clicking a heading toggles the filter to that organization and back off again, so it is not a one-way trip that needs the input to undo. The create form still offered public/private/custom/list. Three of those now mean the same thing, so choosing between them was a dead end; it offers exposure and an organization instead. Creating straight into the right organization matters more than it looks — the organization decides who can already reach the project. Two things found on the way: - The anonymous front page selected on the visibility column, which the access model retired. It went through FilterAccessible like every other caller now, which answers from exposure; a public project whose legacy column says otherwise is still listed, and the test pins that. - CreateOptions ignored exposure entirely, so the JSON API could not set it. It takes both, and translates visibility for callers that predate the field rather than dropping it — a silently ignored field would change a project's reach with nobody told. The API response reports exposure too. Tests: Go for grouping, ordering, the single-org case, the anonymous path and creation into an organization; jsdom for the filters, where the composition of the two and the empty-section case are the parts worth guarding. Assisted-by: Claude Opus 5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marcel M. Otte <marcel.otte@mmo.to>The auto-create branch on upload authenticated without a scope — "no project to scope to" — so a token issued for project A could name a slug that did not exist yet and get project B created and uploaded to. Auto-create is project creation wearing an upload's clothes, and POST /api/projects already refuses scoped tokens; both upload endpoints do now too. Two more from the same corner: The revoke button on Admin > Robots posted to /admin/robots//tokens/N/revoke — $.RobotID does not exist inside the token range, so the id came out empty and the request never reached the handler. Revoking a robot token from the UI has never worked. The handler test built the URL by hand, which is why nobody noticed; the new test asserts on the URL the page actually renders. Token generation took the user id straight from the path, so POST /admin/robots/{anyUserID}/tokens minted a bearer credential for a human account — an admin's, given the right id. It insists on a robot now, and on a project that exists. Generated with AI assistance (Claude Code). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marcel M. Otte <marcel.otte@mmo.to>Every robot was created as an instance editor, and an instance editor may upload to every project. So a robot's token scope was the only thing that ever narrowed it — one nullable column standing alone, which is how a token issued for one project came to create others. Authentication stays token-only: one secret on the wire, no username. What changes is the other half. The token resolves to a user, that user's grants say what it may reach, and the token's project_id narrows that and never widens it. Two checks that both mean something. So a robot now holds the viewer role and is granted like anybody else. The robots page shows and edits those grants, and the create form asks where the robot should be able to upload. MigrateRobotSubjects preserves reach exactly: an existing robot gets an editor grant on every organization — which is what instance editor amounted to — and drops to viewer. Robots an operator promoted to admin are left alone. A project's tokens used to hang off whoever clicked Generate, making the CI credential a slice of one person's account: it carried their access, it put their name on every version it pushed, and it died with them. It names a robot now, created on the spot as {slug}-bot unless another is named, granted editor on that project alone. Auto-create needed the same answer, since it is project creation wearing an upload's clothes: an org-level editor grant is what "may add projects here" means. Exactly one such org and the project lands there; several and it says so rather than guessing. Generated with AI assistance (Claude Code). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Marcel M. Otte <marcel.otte@mmo.to>