Authorization should come from Authentik: drop the email_verified gate, take roles from groups #149

Closed
opened 2026-08-08 13:36:42 +00:00 by claude-bot · 4 comments

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, and sub arrives. The callback then 403s.

{"level":"WARNING","logger":"app.api.routes.auth",
 "message":"Login refused: unverified email claim for sub=c212cf5268cf878b…"}
GET /api/auth/callback -> 403

Part 1 — the email_verified gate

backend/app/api/routes/auth.py refuses any login whose userinfo lacks a truthy email_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:

  • Authentik's built-in email scope mapping returns "email_verified": False hard-coded. Not missing — deliberately false, because Authentik has no email-verification flow of its own.
  • That mapping is a managed object (goauthentik.io/providers/oauth2/scope-email). Editing it in place is reset on Authentik upgrade, so the fix would work and then silently vanish.
  • The workaround is a per-provider custom scope mapping duplicating the built-in with 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:

Anyone allowed to access Circa (according to Authentik) should be allowed to login at the basic level. Roles within Circa can be managed via Authentik groups, so Circa should be able to read groups.

Today is_email_allowed() gates account creation on CIRCA_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 viewer account, and Circa's role (viewer / reviewer / admin) is derived from the groups claim.

This is already available — no Authentik-side work

Verified against the live provider rather than assumed:

  • Authentik's built-in profile scope mapping already emits "groups": [group.name for group in request.user.groups.all()]
  • profile is already bound to the Circa provider, alongside openid and email
  • include_claims_in_id_token = true, so the claim arrives in the ID token Authlib already parses — no extra userinfo round trip

So 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 as weatherbot-admins / weatherbot-users and claude-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-reviewers would follow it.

Decisions for whoever picks this up

Not prescribing the design, but these are the parts that will bite:

  1. Precedence against the in-app role model. #57-era behaviour makes promotion a deliberate admin action, and the first-admin bootstrap was tightened to fire only once so a later address takeover could not retro-promote. If groups become authoritative and are re-evaluated per login, in-app role edits get overwritten — which is the point, but it has to be stated, and it makes the existing first-admin bootstrap mostly redundant when group mapping is configured.
  2. Removal from a group should demote on next login, or the "manage it in Authentik" promise is only true in one direction.
  3. Do the email allowlists survive as an optional second gate, or are they superseded? Both defensible; leaving both active silently is the bad outcome.
  4. Group names are strings from an external system. A renamed Authentik group silently demotes everyone in it; worth failing loudly or logging plainly rather than quietly producing viewers.

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 user rbrooks. That matches how every other dev application on this instance is gated — HomeBooks Dev, Quest Board - Dev, Tea Leaves - Dev, Thespiary Dev, Weatherbot-test and Mailroom Dev are 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.com is unusable until then — no stopgap is being applied to Authentik, by the deployment owner's decision.

**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, and `sub` arrives. The callback then 403s. ``` {"level":"WARNING","logger":"app.api.routes.auth", "message":"Login refused: unverified email claim for sub=c212cf5268cf878b…"} GET /api/auth/callback -> 403 ``` ## Part 1 — the `email_verified` gate `backend/app/api/routes/auth.py` refuses any login whose userinfo lacks a truthy `email_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: - Authentik's built-in `email` scope mapping returns `"email_verified": False` **hard-coded**. Not missing — deliberately false, because Authentik has no email-verification flow of its own. - That mapping is a **managed object** (`goauthentik.io/providers/oauth2/scope-email`). Editing it in place is reset on Authentik upgrade, so the fix would work and then silently vanish. - The workaround is a per-provider custom scope mapping duplicating the built-in with `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: > Anyone allowed to access Circa (according to Authentik) should be allowed to login at the basic level. Roles within Circa can be managed via Authentik groups, so Circa should be able to read groups. Today `is_email_allowed()` gates account creation on `CIRCA_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 `viewer` account, and Circa's role (viewer / reviewer / admin) is derived from the `groups` claim. ### This is already available — no Authentik-side work Verified against the live provider rather than assumed: - Authentik's built-in `profile` scope mapping already emits `"groups": [group.name for group in request.user.groups.all()]` - `profile` is already bound to the Circa provider, alongside `openid` and `email` - `include_claims_in_id_token = true`, so the claim arrives in the ID token Authlib already parses — no extra userinfo round trip So 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 as `weatherbot-admins` / `weatherbot-users` and `claude-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-reviewers` would follow it. ## Decisions for whoever picks this up Not prescribing the design, but these are the parts that will bite: 1. **Precedence against the in-app role model.** #57-era behaviour makes promotion a deliberate admin action, and the first-admin bootstrap was tightened to fire only once so a later address takeover could not retro-promote. If groups become authoritative and are re-evaluated per login, in-app role edits get overwritten — which is the point, but it has to be stated, and it makes the existing first-admin bootstrap mostly redundant when group mapping is configured. 2. **Removal from a group** should demote on next login, or the "manage it in Authentik" promise is only true in one direction. 3. **Do the email allowlists survive** as an optional second gate, or are they superseded? Both defensible; leaving both active silently is the bad outcome. 4. **Group names are strings from an external system.** A renamed Authentik group silently demotes everyone in it; worth failing loudly or logging plainly rather than quietly producing viewers. ## 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 user `rbrooks`. That matches how every other dev application on this instance is gated — `HomeBooks Dev`, `Quest Board - Dev`, `Tea Leaves - Dev`, `Thespiary Dev`, `Weatherbot-test` and `Mailroom Dev` are 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.com` is unusable until then — no stopgap is being applied to Authentik, by the deployment owner's decision.
Author

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 resolved group_id and policy_id on authentik_policies_policybinding but never user_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 Photos and Jellyfin rather than the user-bound dev pattern.

**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 resolved `group_id` and `policy_id` on `authentik_policies_policybinding` but never `user_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 Photos` and `Jellyfin` rather than the user-bound dev pattern.
Author

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_EMAILS and is_email_allowed() all go. Authenticating successfully is sufficient for a viewer account, 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_verified becomes 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 on provider_sub rather 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_EMAILS removes the only route to admin that does not come from a group. So an unset CIRCA_ADMIN_GROUP in 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 the weatherbot-admins / weatherbot-users precedent the issue identifies.

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_EMAILS` and `is_email_allowed()` all go. Authenticating successfully is sufficient for a `viewer` account, 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_verified` becomes 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 on `provider_sub` rather 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_EMAILS` removes the only route to admin that does not come from a group. So an unset `CIRCA_ADMIN_GROUP` in 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 the `weatherbot-admins` / `weatherbot-users` precedent the issue identifies.
Author

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 viewer account, and viewer reads the entire archive: every photograph, every note, every comment.

The two failure modes are not symmetric:

  • Forget CIRCA_OIDC_ADMIN_GROUP → fails closed. Production refuses to start and names the variable.
  • Forget the Authentik group binding on the production application → fails open. Every account on the instance can read the family archive, and nothing in Circa will complain, because Circa cannot see the provider's policy bindings.

On Circa (dev) this is safe and in fact narrower than before — it is user-bound to rbrooks, per the correction above. The exposure is entirely about the production application, which does not exist yet.

So the group binding on Circa production is now a security control with no automated guard behind it. It has to be in place before the application is usable, not after — following Immich Photos and Jellyfin, 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 an OIDC infix:

CIRCA_OIDC_ADMIN_GROUP required; production refuses to start without it
CIRCA_OIDC_REVIEWER_GROUP optional
CIRCA_OIDC_GROUPS_CLAIM default groups; only needed if the provider names it otherwise
CIRCA_REQUIRE_VERIFIED_EMAIL default false

Worth being precise about, because getting it wrong fails in a confusing way: pydantic ignores unknown CIRCA_* variables, so an Ansible role setting CIRCA_ADMIN_GROUP would 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 write user.role — admin-gated, emits user_role_changed, revokes sessions on demotion. There is no frontend for it; nothing in frontend/src/api/ calls /api/users. It now returns 409 while a mapping is configured. is_active stays 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

  • Demotion takes effect immediately across existing sessions. get_current_user re-reads the user row on every request, so a demotion at login applies to that person's other live sessions without revoking anything.
  • Nothing keys on the email address any more, which is what makes CIRCA_REQUIRE_VERIFIED_EMAIL=false sound rather than merely convenient. UserRepository.get_by_email has 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 email claim collides on the unique index and 500s. Not reachable against this Authentik (the email scope 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.

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 `viewer` account, and `viewer` reads the entire archive: every photograph, every note, every comment. The two failure modes are not symmetric: - **Forget `CIRCA_OIDC_ADMIN_GROUP` → fails closed.** Production refuses to start and names the variable. - **Forget the Authentik group binding on the production application → fails open.** Every account on the instance can read the family archive, and *nothing in Circa will complain*, because Circa cannot see the provider's policy bindings. On `Circa (dev)` this is safe and in fact narrower than before — it is user-bound to `rbrooks`, per the correction above. The exposure is entirely about the production application, which does not exist yet. **So the group binding on `Circa` production is now a security control with no automated guard behind it.** It has to be in place *before* the application is usable, not after — following `Immich Photos` and `Jellyfin`, 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 an `OIDC` infix:** | | | |---|---| | `CIRCA_OIDC_ADMIN_GROUP` | required; production refuses to start without it | | `CIRCA_OIDC_REVIEWER_GROUP` | optional | | `CIRCA_OIDC_GROUPS_CLAIM` | default `groups`; only needed if the provider names it otherwise | | `CIRCA_REQUIRE_VERIFIED_EMAIL` | default `false` | Worth being precise about, because getting it wrong fails in a confusing way: pydantic ignores unknown `CIRCA_*` variables, so an Ansible role setting `CIRCA_ADMIN_GROUP` would 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 write `user.role` — admin-gated, emits `user_role_changed`, revokes sessions on demotion. **There is no frontend for it**; nothing in `frontend/src/api/` calls `/api/users`. It now returns 409 while a mapping is configured. `is_active` stays 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 - **Demotion takes effect immediately across existing sessions.** `get_current_user` re-reads the user row on every request, so a demotion at login applies to that person's other live sessions without revoking anything. - **Nothing keys on the email address any more**, which is what makes `CIRCA_REQUIRE_VERIFIED_EMAIL=false` sound rather than merely convenient. `UserRepository.get_by_email` has 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 `email` claim collides on the unique index and 500s. Not reachable against this Authentik (the `email` scope 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.
Author

Released as v0.3.3, and the images are in the registry — confirmed by querying it, not by CI going green:

git.rhoving.com/rbrooks/circa-backend:0.3.3
git.rhoving.com/rbrooks/circa-frontend:0.3.3

Rhoving/iac-repo PR #263 is updated and pinned to 0.3.3. The two halves have to land together: against 0.3.2 the new CIRCA_OIDC_* settings are ignored and the removed ones are still required, so deploying either alone leaves circa-dev exactly as it is now — unable to log in.

Before the redeploy

Create circa-admins and circa-reviewers in Authentik and add yourself to circa-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 groups received and the role assigned, at INFO — the confirmation the chain works end to end; or
  • a warning naming the exact claim it looked for, if the claim is absent. That is the misconfiguration case: scope not bound to the client, or Authentik sending it under another name.

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, publish gates 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 the circa.rhoving.com vhost 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.

**Released as v0.3.3, and the images are in the registry** — confirmed by querying it, not by CI going green: ``` git.rhoving.com/rbrooks/circa-backend:0.3.3 git.rhoving.com/rbrooks/circa-frontend:0.3.3 ``` `Rhoving/iac-repo` PR #263 is updated and pinned to 0.3.3. **The two halves have to land together**: against 0.3.2 the new `CIRCA_OIDC_*` settings are ignored *and* the removed ones are still required, so deploying either alone leaves `circa-dev` exactly as it is now — unable to log in. ## Before the redeploy **Create `circa-admins` and `circa-reviewers` in Authentik and add yourself to `circa-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 groups received and the role assigned, at INFO — the confirmation the chain works end to end; or - a warning naming the exact claim it looked for, if the claim is absent. That is the misconfiguration case: scope not bound to the client, or Authentik sending it under another name. ## 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, `publish` gates 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 the `circa.rhoving.com` vhost 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.
Sign in to join this conversation.
No description provided.