One access model: organizations, groups and grants (#150, #151) #154

Merged
qwc merged 17 commits from feature/access-redesign into main 2026-09-02 21:42:25 +02:00
Owner

Closes #150 and #151.

Four mechanisms granted access, with three copies of one shape. global_access and access_list_members have identical columns(subject_type, subject_identifier, role) — and auth_group_mappings is 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:

access_groups          a named set of subjects (users and/or auth groups)
access_group_members   membership — deliberately WITHOUT a role
access_group_resolved  what the login sync worked out for one user
access_grants          group-or-user -> org-or-project, with a role
orgs                   the container above projects

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

  • The role is on the grant, not on the membership. This is the crux. access_list_members.role forced a list to carry one role everywhere, so "engineering edits A but only reads B" needed two lists. Now it is two rows.
  • A grant's subject can be a single user, not only a group — otherwise adding one colleague means inventing a group of one.
  • access_grants keeps 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.
  • Exposure replaces the four visibility values: public / any signed-in user / granted only. The old four differed only in which grants applied, which the grant table now decides on its own. "Any signed-in user" is new — the old model could not express it.
  • Organizations never appear in URLs. Project slugs stay globally unique, so nothing breaks and an org slug cannot collide with a project's (/admin, /search would have been the real collision, not "No Org").

The safety net

internal/access/migration_equivalence_test.go builds 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 in intendedChanges.

Reviewing it

Commit by commit, in order — each stands on its own:

  1. schema (migration 016, all three dialects), models, stores
  2. MigrateAccessModel + Resolver + the equivalence test
  3. login sync records group membership
  4. the flip — checker → resolver, plus the whole admin UI
  5. retirement of the replaced pages
  6. config.yaml declares the model
  7. front page, create form, admin filters, and fixes found along the way

Before you run it at work

Since you are testing with real LDAP and exported production data on Kubernetes:

  • The migration is one-shot, guarded by app_meta.access_model_migrated in one transaction. Testing against a copy of production is exactly right; re-running needs a fresh copy or that row cleared.
  • Watch the startup line. 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 synced project_access rows 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.
  • LDAP access materialises at login, not at migration. A group whose members are LDAP groups grants nothing until affected users sign in — inherent, and the same as before (issue #135). So verify by logging in as an LDAP user and checking access_group_resolved, not by reading the tables straight after startup.
  • Multiple replicas all run the migration at startup. The last commit here makes the losers block on the marker and skip rather than duplicate the work and fail startup; TestConcurrentMigrationRunsOnce covers it. Worth knowing it was a real crash-loop before that fix.
  • access.private in config is no longer applied and warns loudly at startup; auth.*.project_groups still 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_access and access.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

Closes #150 and #151. Four mechanisms granted access, with three copies of one shape. `global_access` and `access_list_members` have **identical columns** — `(subject_type, subject_identifier, role)` — and `auth_group_mappings` is 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: ``` access_groups a named set of subjects (users and/or auth groups) access_group_members membership — deliberately WITHOUT a role access_group_resolved what the login sync worked out for one user access_grants group-or-user -> org-or-project, with a role orgs the container above projects ``` **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 - **The role is on the grant, not on the membership.** This is the crux. `access_list_members.role` forced a list to carry one role everywhere, so "engineering edits A but only reads B" needed two lists. Now it is two rows. - **A grant's subject can be a single user, not only a group** — otherwise adding one colleague means inventing a group of one. - **`access_grants` keeps 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. - **Exposure replaces the four visibility values**: public / any signed-in user / granted only. The old four differed only in *which* grants applied, which the grant table now decides on its own. "Any signed-in user" is new — the old model could not express it. - **Organizations never appear in URLs.** Project slugs stay globally unique, so nothing breaks and an org slug cannot collide with a project's (`/admin`, `/search` would have been the real collision, not "No Org"). ## The safety net `internal/access/migration_equivalence_test.go` builds 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 in `intendedChanges`. ## Reviewing it Commit by commit, in order — each stands on its own: 1. schema (migration 016, all three dialects), models, stores 2. `MigrateAccessModel` + `Resolver` + the equivalence test 3. login sync records group membership 4. **the flip** — checker → resolver, plus the whole admin UI 5. retirement of the replaced pages 6. `config.yaml` declares the model 7. front page, create form, admin filters, and fixes found along the way ## Before you run it at work Since you are testing with real LDAP and exported production data on Kubernetes: - **The migration is one-shot**, guarded by `app_meta.access_model_migrated` in one transaction. Testing against a *copy* of production is exactly right; re-running needs a fresh copy or that row cleared. - **Watch the startup line.** `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 synced `project_access` rows 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. - **LDAP access materialises at login, not at migration.** A group whose members are LDAP groups grants nothing until affected users sign in — inherent, and the same as before (issue #135). So verify by logging in as an LDAP user and checking `access_group_resolved`, not by reading the tables straight after startup. - **Multiple replicas** all run the migration at startup. The last commit here makes the losers block on the marker and skip rather than duplicate the work and fail startup; `TestConcurrentMigrationRunsOnce` covers it. Worth knowing it was a real crash-loop before that fix. - **`access.private` in config is no longer applied** and warns loudly at startup; `auth.*.project_groups` still 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_access` and `access.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
qwc added 12 commits 2026-09-01 21:33:55 +02:00
Four mechanisms grant access today, with three copies of one shape:
global_access rules, access_list_members and auth_group_mappings all store
(subject_type, subject_identifier, role), and global_access_grants,
access_list_grants and project_access are three parallel resolved-grant
tables. A project's real permissions were spread across all four.

This lays down the replacement — one noun and one edge — without reading it
anywhere yet:

  orgs                   the container above projects; every project has one
  access_groups          a named set of subjects (users and/or auth groups)
  access_group_members   membership, deliberately WITHOUT a role
  access_group_resolved  what the login sync worked out for one user
  access_grants          group-or-user -> org-or-project, with a role

The role lives on the grant, not on the membership. That is the point of the
change: access_list_members.role forced a list to carry one role everywhere,
so "engineering edits A but only reads B" needed two lists.

access_grants keeps real foreign keys on all four of its group/user/org/project
columns rather than a polymorphic (subject_type, subject_id) pair, so a grant
dies with the thing it points at. An orphan row that later matched a reused id
would grant access to a different project.

projects gains org_id (backfilled to a 'default' org named "No Org") and
exposure, which replaces the four visibility values with the only question
left for a project to answer: public, authenticated, or granted. visibility
stays until nothing reads it.

ProjectStore now defaults both new columns rather than trusting callers:
exposure to 'granted' and org_id to the default org, so neither can be
silently empty.

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 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 login-time authorization job shrinks to recording one fact — this user is
in these access groups — and lets the grant edge turn that into roles at check
time. Each source previously worked out roles for projects, for global access
and for access lists separately, from three tables holding the same shape.

Members naming a user still need nothing at login: they are matched by
username when access is checked, so naming someone takes effect immediately
rather than waiting for a sign-in that may never come (issue #135).

SetAccessGroups is a separate setter rather than a sixth positional parameter
on SetStores, so the transitional wiring does not churn every call site. Both
syncs run until the old tables are retired.

main.go runs MigrateAccessModel before serving, and the handler now carries a
Resolver alongside the existing Checker. Nothing reads the Resolver yet — the
checker flip and the admin UI have to land together, or the UI would write to
tables the checker no longer reads, which is exactly the silently-ignored
write this whole redesign is meant to end.

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>
Their tables still hold what the migration read, and stay until it has been
confirmed good in production — that data is the only way back if something is
wrong, and the equivalence test needs it to keep proving anything. But nothing
consults them at runtime any more, so leaving their forms accepting edits
would have shipped the precise bug this redesign set out to end: a page that
saves where nothing reads.

Gone: the Group Mappings, Global Access and Access Lists pages, their
handlers and templates, and the per-project access grant/revoke routes that
wrote project_access rows. Their addresses redirect to what replaced them,
explaining why, because they are bookmarked and linked from the docs.

The project edit form no longer offers an access list, and clears any pointer
a project still carries rather than leaving a row that implies it means
something.

Tests for the retired UI go with it. Two are replaced rather than deleted,
because the property outlived the mechanism: that non-admins cannot manage
access that spans projects, and that the edit page offers grants.

The two how-to guides now say they describe a retired mechanism and point at
how-to/manage-access.md.

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>
It still advertised three-tier visibility, global access lists and group
mappings — the four mechanisms this branch replaced. The readme is the first
thing anyone reads about the project, so leaving it describing a model the
code no longer has is the same category of stale as an out-of-date doc page.

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>
"Nothing matches those filters." showed on every page load whatever was in
the boxes. Removed, as asked — an empty grid says the same thing without a
line of text that has to be kept correct.

The cause was worse than the symptom. The only rule for the class was
.project-card.hidden, scoped to cards, so nothing else could be hidden by
setting it. That message was one casualty; the other was org sections: a
filtered-out organization kept its heading above an empty grid, which is
exactly what the code comment claimed to prevent. There is a generic .hidden
now.

The jsdom test asserted the class list, so it passed while the page was
visibly wrong — it proved the JavaScript and nothing about the outcome. It
loads the real stylesheet now and asserts getComputedStyle, and was confirmed
to fail against the old rule before being kept.

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>
Organizations and access groups both render one card each, and both lists grow
without bound on a real instance. A filter box appears on each once there is
more than one item — over a single card it is furniture, and the frontpage
already sets that precedent for chrome that only earns its place at scale.

One script serves both, driven by markup rather than by knowledge of either
page: an input names the items it filters, an item carries the text to match
on. Adding a third list should mean adding markup, not JavaScript. Cards match
on their description and slug too, not just their name, which is what makes
the attribute worth carrying.

Tests: Go for the appears-only-when-worth-it rule and for cards carrying their
match text; jsdom for the filtering itself, asserting against the real
stylesheet rather than the class list, and covering a page that loads the
script with no filter on it — every admin page will, and the single-item ones
render none.

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>
Claim the migration marker before doing the work, not after
All checks were successful
CI / test (pull_request) Successful in 1m24s
CI / build (pull_request) Successful in 47s
CI / docker (pull_request) Has been skipped
24c681aece
This is deployed to Kubernetes, so several replicas start at once and all
reach the access migration having read "not migrated". Writing the marker last
meant every one of them translated the entire access model and only then
collided on its primary key — the losers wasted the work and failed startup,
which main.go turns into os.Exit(1) and Kubernetes into a crash loop that
resolves itself only on the restart after the winner commits.

Claiming first inverts it: the losers block on that key, find the marker set
once the winner commits, and skip. Any error on the claim is handled the same
way — re-read the marker rather than pick apart dialect-specific constraint
violations. If the winner rolls back instead, the claim succeeds and that
replica does the work.

TestConcurrentMigrationRunsOnce starts four at once and asserts none of them
errors and that the result matches a single run exactly, since duplicated
grants would show up as a different count.

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>
Collapse the organization and access-group cards
All checks were successful
CI / test (pull_request) Successful in 1m21s
CI / build (pull_request) Successful in 46s
CI / docker (pull_request) Has been skipped
819b9043ac
Twenty organizations meant twenty open forms stacked down the page. Each is a
<details> now: collapsed it is one line — the name, the count in its own
centred column so counts align down the list, and the description small
underneath over the full width. Edit unfolds the fields, tables and buttons;
Close folds them back.

The description hides while the card is open, because the edit field below it
says the same thing.

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>
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>
Three dormant things found while mapping how tokens are scoped.

**scopes** was written as "upload" at both creation sites and read nowhere, so
it described nothing — an "upload" token created projects happily, because
creation was never checked against it. Creating is its own permission now, and
MigrateTokenScopes writes what each existing token could already do: a global
token creates projects, a project-scoped one never could. Without that backfill
the upgrade would quietly revoke creation from CI jobs that rely on it.

**expires_at** was honoured by the authenticator and set by nothing, so every
token was eternal. Both token forms offer an expiry in days now, and both lists
show it.

**The MySQL foreign key** on api_tokens.project_id has never existed. Migration
003 declares it as an inline column-level REFERENCES clause; SQLite and
PostgreSQL make a real constraint from that, InnoDB parses it and throws it
away. So deleting a project left its tokens behind there, pointing at an id
that a restore could hand to a different project. Migration 017 deletes the
orphans and adds the constraint properly; the other two dialects get a no-op at
the same number so a schema can still be talked about by version.

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>
A robot's username is not a login (#155)
All checks were successful
CI / test (pull_request) Successful in 1m23s
CI / build (pull_request) Successful in 46s
CI / docker (pull_request) Has been skipped
e6c296987b
Issuing a project token now creates a robot by name, and anyone who may upload
to a project can do it — so a name is a thing an editor can claim. Both
provisioning paths adopted an existing row by username, which made that claim
dangerous in two directions: the person signing in would inherit the robot's
grants, and whoever held the robot's token would inherit that person's access,
including the group memberships the login sync writes against their user id.

LDAP and OAuth2 refuse a robot's username now instead of signing someone into a
service account. Builtin auth was never exposed to this — a robot has no
password to match.

Two smaller things from the same read: a project-scoped token no longer records
a "create" scope it could not use, and the robots list gets the filter the other
admin lists have.

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>
qwc merged commit 8c1b5040fc into main 2026-09-02 21:42:25 +02:00
qwc deleted branch feature/access-redesign 2026-09-02 21:42:25 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
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
qwc-open/asiakirjat!154
No description provided.