Authorization should come from Authentik: drop the email_verified gate, take roles from groups #149
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
High priority — this blocks the deployment. Circa is running at
circa-dev.rhoving.com(iac-repo#262) and nobody can log in, including the configured first admin. Everything else works: the login page renders, the OIDC round trip completes, the ID token validates, andsubarrives. The callback then 403s.Part 1 — the
email_verifiedgatebackend/app/api/routes/auth.pyrefuses any login whose userinfo lacks a truthyemail_verified. The reasoning in the comment is sound in general: the email claim drives both the allowlist and the admin bootstrap, so an IdP permitting self-registration could let someone claim the admin's address.It does not hold against this identity provider, and the check cannot be satisfied from the IdP side without a fragile workaround:
emailscope mapping returns"email_verified": Falsehard-coded. Not missing — deliberately false, because Authentik has no email-verification flow of its own.goauthentik.io/providers/oauth2/scope-email). Editing it in place is reset on Authentik upgrade, so the fix would work and then silently vanish.True. It works, but it is a copy of a vendor default that now never tracks upstream, repeated per provider, to satisfy a check the deployment does not need.Against this Authentik there is no self-registration path: the only source (
authentik Built-in) has no enrollment flow bound, and the only brand binds an authentication flow rather than an enrollment one. Every account is admin-created, so the address on an Authentik account is administratively assigned — which is the property the check is trying to establish.What is needed: the requirement becomes configurable rather than absolute. Whether the default stays
True(fails closed, this deployment opts out) or flips is the maintainer's call — the deployment side only needs to be able to turn it off, and iac-repo already asserts on the settings it depends on.Part 2 — authorization belongs to the IdP, not to an email list
The larger point, and the reason not to just add a flag and stop. From the deployment's owner:
Today
is_email_allowed()gates account creation onCIRCA_FIRST_ADMIN_EMAILS/CIRCA_ALLOWED_EMAILS/CIRCA_ALLOWED_EMAIL_DOMAINS, and groups are not read at all. That means access is maintained in two places that cannot see each other: Authentik decides who reaches the application, and a comma-separated list in Ansible independently decides who gets an account. Adding a relative is two edits in two systems, and forgetting the second produces a 403 that looks like a broken deployment.What is needed: authenticating successfully against the provider is sufficient for a
vieweraccount, and Circa's role (viewer / reviewer / admin) is derived from thegroupsclaim.This is already available — no Authentik-side work
Verified against the live provider rather than assumed:
profilescope mapping already emits"groups": [group.name for group in request.user.groups.all()]profileis already bound to the Circa provider, alongsideopenidandemailinclude_claims_in_id_token = true, so the claim arrives in the ID token Authlib already parses — no extra userinfo round tripSo the claim is sitting there unread.
The groups that exist
Useful for making the role mapping concrete rather than hypothetical. Member counts as of 2026-08-08:
Brooks Family(5),Fischers(2),Collaboration Group(8),Media Group(17),Productivity(7),Ryan & Audrey(2),Private(1),AI Group(8),The New Romantics Project(2), plus per-application groups such asweatherbot-admins/weatherbot-usersandclaude-monitor-admins/claude-monitor-users.That last pattern is the closest precedent: an application that wants two tiers gets its own pair of groups rather than reusing the family ones.
circa-admins/circa-reviewerswould follow it.Decisions for whoever picks this up
Not prescribing the design, but these are the parts that will bite:
How access is actually gated in Authentik today
(Corrected — an earlier revision of this issue claimed the Circa application had no policy bindings. That was a bad query on my part, not the configuration: it resolved group and policy bindings but never user bindings, so a user binding rendered as "none". See the comment below.)
Circa (dev)is bound, to the userrbrooks. That matches how every other dev application on this instance is gated —HomeBooks Dev,Quest Board - Dev,Tea Leaves - Dev,Thespiary Dev,Weatherbot-testandMailroom Devare all user-bound to individuals rather than to groups. Production applications are group-bound instead (Immich Photos→ Fischers / Brooks Family / Collaboration Group,Jellyfin→ Fischers / Brooks Family / Media Group, and so on).So for dev, delegating authorization to Authentik makes access narrower than the current email allowlist, not wider — one user rather than one address plus whatever a domain rule would admit. The thing to get right is the production application when it is created: it needs its group binding in place before the email allowlist stops being the gate, following the pattern the media applications already use.
Deployment coupling
The Ansible side is iac-repo#262 / PR #263, deployed and waiting. Landing this means a release (the images are built by CI on a version tag), then a pin bump and a redeploy on the iac-repo side.
circa-dev.rhoving.comis unusable until then — no stopgap is being applied to Authentik, by the deployment owner's decision.Correction to the original body. It claimed the
Circa (dev)Authentik application had no policy bindings, and therefore that all 22 accounts on the instance could reach it. That was wrong, and the body has been amended.The application is bound to the user
rbrooks. My query resolvedgroup_idandpolicy_idonauthentik_policies_policybindingbut neveruser_id, so a user binding came back with both joins null and rendered as "none". The configuration was correct throughout; the query was not.It matters for how this issue reads, because it inverts the risk. Delegating authorization to Authentik does not widen dev access — it narrows it, from one email address plus any domain rule to one named user. The real item is the production application when it is created: it needs its group binding before the email allowlist stops being the gate, following
Immich PhotosandJellyfinrather than the user-bound dev pattern.Picking this up. Decisions taken with @rbrooks on the four questions, and one correction to something I proposed.
The decisions
1. Groups are authoritative, re-evaluated every login. In
circa-admins→ admin; removed → demoted at the next login. The consequence stated plainly, because the issue is right that it has to be: in-app role edits become transient. Anything set through Circa is overwritten when that user next signs in. Whatever can change a role today must refuse rather than silently lose the write.2. The email allowlists are removed entirely — not made optional.
CIRCA_ALLOWED_EMAILS,CIRCA_ALLOWED_EMAIL_DOMAINS,CIRCA_FIRST_ADMIN_EMAILSandis_email_allowed()all go. Authenticating successfully is sufficient for avieweraccount, and who may authenticate is the IdP's policy binding — which is where it is already maintained, and, per the correction in the comment above, is currently narrower than the allowlist rather than wider.3. No "never demote the last admin" guard. I proposed one and @rbrooks was right to push back. My reasoning assumed roles are repaired inside Circa, which needs an admin. They are not: the mapping reads entirely from outside and is re-evaluated per login, so a renamed or mistyped group is fixed in Authentik — or in the Ansible pin — and the role returns at the next sign-in. No bootstrap problem and no database surgery. An exception that kept someone admin against the IdP's word would have re-created precisely the drift this removes.
What survives from that idea is the cheap half: the groups received and the role assigned are logged on every login, so a rename is diagnosable from a log line instead of by guessing.
4.
email_verifiedbecomes a setting defaulting to off, rather than staying a hard refusal. Worth being explicit about why the default flips, since the issue left it open: the check's own justification is that "the email claim drives both the allowlist and the admin bootstrap". After this change it drives neither. Entitlement is the IdP's policy binding, the role is the groups claim, and the account has always been keyed onprovider_subrather than the address. The check protects nothing that still exists. It stays available as a setting for a deployment that wants the displayed address to be provider-verified.One consequence that needed handling
Removing
CIRCA_FIRST_ADMIN_EMAILSremoves the only route to admin that does not come from a group. So an unsetCIRCA_ADMIN_GROUPin production would mean an archive that can never have an administrator — while looking like a perfectly healthy deployment.Production now refuses to start with an empty admin group, in the same style as #14's other refusals: named variable, reported at startup, no warn-and-continue. Fails visible rather than into a dead end.
An absent groups claim is not an empty one
Calling this out because it is the part most likely to be got subtly wrong. If the claim key is missing — scope not bound, or the provider names it something else — then every user silently becomes a viewer, including whoever was supposed to be admin, and the deployment looks like it is working. That is logged as a misconfiguration. A claim that is present and empty is a real answer: this person is in no groups.
Rendering "we could not read your groups" and "you have no groups" identically is #78's failure, and here it is the difference between a broken deployment and a correct one.
Deployment coupling
This removes three settings the Ansible role sets and adds three it must set (
CIRCA_ADMIN_GROUP,CIRCA_REVIEWER_GROUP,CIRCA_REQUIRE_VERIFIED_EMAIL). iac-repo#262 / PR #263 needs the same change, including its vault assertion, or the play will fail its own precondition.Sequence to unblock
circa-dev: land this → release → bump the pin → redeploy. And the groups need creating in Authentik first —circa-admins/circa-reviewers, following theweatherbot-admins/weatherbot-usersprecedent the issue identifies.Built. Two corrections to my previous comment, and one security consequence that needs to be read carefully before the production application is created.
⚠️ The security boundary has left the repository, and only half of it fails closed
This is the most important thing on this issue and it is not a defect in the change — it is what the change means.
After this, Circa has no independent authorization gate. Any identity the provider authenticates receives a
vieweraccount, andviewerreads the entire archive: every photograph, every note, every comment.The two failure modes are not symmetric:
CIRCA_OIDC_ADMIN_GROUP→ fails closed. Production refuses to start and names the variable.On
Circa (dev)this is safe and in fact narrower than before — it is user-bound torbrooks, per the correction above. The exposure is entirely about the production application, which does not exist yet.So the group binding on
Circaproduction is now a security control with no automated guard behind it. It has to be in place before the application is usable, not after — followingImmich PhotosandJellyfin, not the user-bound dev pattern. It is in the README as a precondition; iac-repo#262 should assert it if anything on that side can query Authentik.Correction 1 — the environment variable names
My deployment note said
CIRCA_ADMIN_GROUP/CIRCA_REVIEWER_GROUP. The actual names carry anOIDCinfix:CIRCA_OIDC_ADMIN_GROUPCIRCA_OIDC_REVIEWER_GROUPCIRCA_OIDC_GROUPS_CLAIMgroups; only needed if the provider names it otherwiseCIRCA_REQUIRE_VERIFIED_EMAILfalseWorth being precise about, because getting it wrong fails in a confusing way: pydantic ignores unknown
CIRCA_*variables, so an Ansible role settingCIRCA_ADMIN_GROUPwould have it silently dropped and production would then refuse to start on the admin-group check — a real failure pointing at the wrong thing.Removed, and now silently ignored rather than honoured:
CIRCA_ALLOWED_EMAILS,CIRCA_ALLOWED_EMAIL_DOMAINS,CIRCA_FIRST_ADMIN_EMAILS.Correction 2 — my spec was internally inconsistent, and the implementation is right
I asked for the role to be assigned on every login unconditionally, and separately for
PATCH /api/users/{id}to refuse role changes when a group is configured. Those two do not agree: with unconditional assignment, a PATCH accepted on an unconfigured instance would still be undone at the user's next login — a silent loss exactly where the refusal does not reach.Both are now gated on the same condition: when the provider grants roles it owns them completely; when it grants none, #73's in-application administration still works. Identical in production either way, since production cannot start without an admin group.
What in-app role management turned out to be
PATCH /api/users/{user_id}is the only thing outside the auth routes that can writeuser.role— admin-gated, emitsuser_role_changed, revokes sessions on demotion. There is no frontend for it; nothing infrontend/src/api/calls/api/users. It now returns 409 while a mapping is configured.is_activestays writable deliberately: the callback never touches it, so deactivation is the one lever the deployment keeps over an account the provider still authenticates.Two things verified rather than assumed
get_current_userre-reads the user row on every request, so a demotion at login applies to that person's other live sessions without revoking anything.CIRCA_REQUIRE_VERIFIED_EMAIL=falsesound rather than merely convenient.UserRepository.get_by_emailhas zero call sites — it is dead code, and it is the one thing that could quietly make the address load-bearing again.Filed separately
#150 — a second login carrying no
emailclaim collides on the unique index and 500s. Not reachable against this Authentik (theemailscope is bound and every account is admin-created with an address), and not a privilege issue, but it is a crash where there used to be a clean refusal, and every fix is a product decision this issue had not taken.Numbers
1558 backend tests, up from 1516. Fourteen mutants, all caught — including reversed admin/reviewer precedence, an absent claim treated as empty, and the role never being re-applied for an existing user, which is the demotion path and the one most likely to regress.
Released as v0.3.3, and the images are in the registry — confirmed by querying it, not by CI going green:
Rhoving/iac-repoPR #263 is updated and pinned to 0.3.3. The two halves have to land together: against 0.3.2 the newCIRCA_OIDC_*settings are ignored and the removed ones are still required, so deploying either alone leavescirca-devexactly as it is now — unable to log in.Before the redeploy
Create
circa-adminsandcirca-reviewersin Authentik and add yourself tocirca-admins. The play asserts the admin group is configured; it cannot check that the group exists. A missing group is not an error anywhere — it just makes every login a viewer, including yours.That case is at least visible now rather than silent. On the first sign-in the log carries either:
The tag was moved once
Worth recording since it is visible in the history. v0.3.3 was tagged, its CI run flaked on an unrelated 5-second test timeout,
publishgates on that job, and no image was built — a tag that existed with nothing behind it. The timeout is fixed (#151, and it reverses the narrower judgement made in #147), the tag was moved onto that fix, and only then did the images appear. Nothing had consumed the original commit, so the move cost nothing.That failure mode is worth knowing generally: a frontend flake on a tag push does not look like a broken test, it looks like a registry that is missing a version.
Still true, and still the thing to watch
Circa has no independent authorization gate any more. The provider's policy binding is the whole of it, and Circa cannot see that binding to complain about it. Dev is user-bound to
rbrooks, so it is narrower than the allowlist it replaces — and I see thecirca.rhoving.comvhost has been dropped for now, so there is no production application to get this wrong on yet. When one is stood up, its group binding is a security control with nothing automated behind it.