Resolve access list group membership at login (#125) #138

Merged
qwc merged 1 commit from feature/access-list-login-sync into main 2026-08-30 19:02:02 +02:00
Owner

Third of the #125 series, on top of #136 and #137. Fills in the half of a list the checker can't resolve by itself.

What it does

A list member naming a user is matched by username when access is checked — that already works. A member naming an LDAP or OAuth2 group can't be: group membership is only known while the user is signing in. This PR makes both authenticators record the result as access_list_grants rows, the same way they already resolve global_access group rules.

One implementation, not two

syncAccessListGrants lives in internal/auth/access_lists.go and both authenticators call it. LDAP and OAuth2 differ only in the subject type they match (ldap_group / oauth2_group) and the source they record, so a copy each would be two statements of the same authorization rule — which is exactly how canViewProject drifted into three versions before the access package was extracted.

Reconcile, don't rewrite

The obvious implementation is "delete this source's grants, then re-grant what matches". I avoided it: that leaves a window mid-login where a user who never lost access has no grant, and a concurrent request would see a 403. Instead the sync reads what the source already holds and applies the difference — a user still in the group keeps an unbroken grant, and only genuinely removed memberships are deleted.

Grants from the other source are never touched, and members naming a user directly aren't grants at all, so no sync can disturb them.

Tests

internal/auth/access_lists_test.go:

  • Grant + strongest role — two of a list's groups match with different roles; editor is recorded. Group names compare case-insensitively, as elsewhere in the authenticators.
  • Reconcile — a user in two groups loses one; that list's grant goes, the other is untouched.
  • Source isolation — an LDAP login that matches nothing leaves an oauth2-sourced grant and a directly-named member alone.
  • Full login path — through Authenticate with the mock LDAP dialer: signing in grants editor on the list, and signing in again after leaving the group revokes it.

Full suite green. (gofmt flags oauth2_test.go and token_test.go — both pre-existing on main, in blocks this PR doesn't touch.)

Note on SetStores

Both authenticators' SetStores gained an accessLists parameter. Existing test call sites pass nil, and a nil store means "no access lists" everywhere, so LDAP/OAuth2 deployments that never create a list behave exactly as before.

Last step

Step 4 — the admin UI: managing lists and their members, list as a choice in the project visibility picker (wired to projects.ValidateVisibility rather than another copy of the literal check), and the built-in docs. After that visibility = 'list' becomes reachable and #125 is done.


Assisted by Claude Opus 5.

Third of the #125 series, on top of #136 and #137. Fills in the half of a list the checker can't resolve by itself. ## What it does A list member naming a **user** is matched by username when access is checked — that already works. A member naming an **LDAP or OAuth2 group** can't be: group membership is only known while the user is signing in. This PR makes both authenticators record the result as `access_list_grants` rows, the same way they already resolve `global_access` group rules. ## One implementation, not two `syncAccessListGrants` lives in `internal/auth/access_lists.go` and both authenticators call it. LDAP and OAuth2 differ only in the subject type they match (`ldap_group` / `oauth2_group`) and the source they record, so a copy each would be two statements of the same authorization rule — which is exactly how `canViewProject` drifted into three versions before the `access` package was extracted. ## Reconcile, don't rewrite The obvious implementation is "delete this source's grants, then re-grant what matches". I avoided it: that leaves a window mid-login where a user who never lost access has no grant, and a concurrent request would see a 403. Instead the sync reads what the source already holds and applies the difference — a user still in the group keeps an unbroken grant, and only genuinely removed memberships are deleted. Grants from the *other* source are never touched, and members naming a user directly aren't grants at all, so no sync can disturb them. ## Tests `internal/auth/access_lists_test.go`: - **Grant + strongest role** — two of a list's groups match with different roles; editor is recorded. Group names compare case-insensitively, as elsewhere in the authenticators. - **Reconcile** — a user in two groups loses one; that list's grant goes, the other is untouched. - **Source isolation** — an LDAP login that matches nothing leaves an oauth2-sourced grant and a directly-named member alone. - **Full login path** — through `Authenticate` with the mock LDAP dialer: signing in grants editor on the list, and signing in again after leaving the group revokes it. Full suite green. (`gofmt` flags `oauth2_test.go` and `token_test.go` — both pre-existing on `main`, in blocks this PR doesn't touch.) ## Note on `SetStores` Both authenticators' `SetStores` gained an `accessLists` parameter. Existing test call sites pass `nil`, and a nil store means "no access lists" everywhere, so LDAP/OAuth2 deployments that never create a list behave exactly as before. ## Last step **Step 4** — the admin UI: managing lists and their members, `list` as a choice in the project visibility picker (wired to `projects.ValidateVisibility` rather than another copy of the literal check), and the built-in docs. After that `visibility = 'list'` becomes reachable and #125 is done. --- Assisted by Claude Opus 5.
Resolve access list group membership at login (#125)
All checks were successful
CI / test (pull_request) Successful in 1m23s
CI / build (pull_request) Successful in 47s
CI / docker (pull_request) Has been skipped
0a30993627
Third step of named access lists: fill in the half of a list the checker
cannot resolve on its own. A member naming a user is matched by username
at check time, but a member naming an LDAP or OAuth2 group needs the
login sync, since group membership is only known during sign-in.

Both authenticators call one shared syncAccessListGrants rather than
keeping a copy each: they differ only in the subject type they match and
the source they record, and two copies of an authorization rule is how
they drift apart.

The sync reconciles against the grants that source already holds instead
of deleting them and re-granting, so a user still in the group keeps an
unbroken grant with no window mid-login where they lose access. Grants
from the other source, and members that name a user directly, are left
untouched.

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>
qwc merged commit 6319bdc75e into main 2026-08-30 19:02:02 +02:00
qwc deleted branch feature/access-list-login-sync 2026-08-30 19:02:02 +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!138
No description provided.