feat(identity): PetalNet Identity, first slice - #399
Open
returnsvoidjanet wants to merge 4 commits into
Open
Conversation
added 2 commits
August 12, 2026 17:27
An identity control plane over Authentik that reports what access is INTENDED, what is CONFIGURED, and what Authentik actually EVALUATES for a named person. Authentik stays the authentication source of truth; nothing here reimplements sessions, MFA or OAuth, and every call in this slice is a GET. What it does, against live data: - Role inheritance and explicit deny, with the resolution order deny-direct > deny-inherited > allow-direct > allow-inherited > default-deny. Deny wins at every distance so an exception carved high in the tree cannot be defeated by a leaf grant. - An explanation tree for every decision. A bare allow/deny is unauditable. - Cycle detection that names the cycle, including in a branch nobody holds yet. - The EVALUATED state, by asking Authentik to evaluate its own policies per named user. This covers every SSO application with no connector and no impersonation. - Deterministic findings: unbound applications, and the disagreements between the three states. - The onboarding gate: creating a person is blocked while any application still admits every authenticated account. Notes on things that are deliberate rather than unfinished: Reconciliation runs off the request path and its result is cached with the time it was produced. Doing ~800 checks inside a page load took 36 seconds; a worker pool got it to 15, which was still the wrong fix. The header now states how many checks, how long they took, and how old they are, which is the evidence model the design asks for anyway. A failed check is `unknown`, never `deny`. Folding one into the other manufactures BROKEN_ACCESS findings out of network errors. Likewise, a failed read of Authentik renders a loud error rather than an empty dashboard that reads as a clean bill of health. 27 tests cover the resolver, including the worked examples from the design and a control proving the cycle tests fail when cycle detection is removed. Not built yet, on purpose: Jellyfin discovery, the Migration Center, real access probes, bulk invite, rollback and the graph view.
A new workspace package needs its lockfile entry or a --frozen-lockfile install fails in CI. Separate commit because it is generated, not authored.
The previous commit could only read. This adds the half that makes it a tool: tap a person, tap a role, see exactly what they gain and lose before anything happens, apply, and have it proved afterwards. Every mutation is simulate -> show impact -> apply -> verify. The prediction is the load-bearing part: applying and then reporting whatever happened is easy and worthless, because without a prediction there is nothing for the verification to disagree with. Postconditions assert the gains as allows AND a sample of things that must NOT change, since checking only the gains would let a change that accidentally opened something else pass as verified. Writes live in their own module so that the resolver, the reconciler and the simulation are structurally unable to mutate Authentik. Most of the code cannot reach the write path at all. High-impact changes require the target username typed back. Impact is judged on both breadth and sensitivity, because one resource can matter more than eight. Phone first: single column, thumb-sized rows, no tables. Verified at 390x844 with zero horizontal overflow and no console errors. Two defects found by driving a real change through the UI rather than by reading the code: - The design tokens lived in the home page scoped style block, so loading a person page directly gave a page with no --ink and no --rule. Every declaration referencing them was dropped and the page rendered as unstyled serif text. It typechecked, returned 200 and logged no errors. Only looking at it caught it. Tokens now live in app.css behind a root layout. - Verification asserted deny on ldap, which is unbound and therefore admits everyone. A correct change came back "not verified" because of a standing, unrelated problem, which is how an operator learns to ignore a red banner. Unbound applications are now excluded from the deny sample, with a test and a positive control proving bound ones still are not. eslint.config.ts gains apps/identity in the list of workspaces whose .svelte files get the Svelte parser. Without it the components were silently unlinted. 44 tests. Full check job green on this app.
Adds the onboarding half: paste a roster, get one claim link per person, hand them out. Nobody's password ever passes through the operator, because the invitee sets it themselves during claim. The Authentik side had to be fixed first, and it was worse than expected. The petalnet-migration enrollment flow was set require_unauthenticated with no invitation stage and no policy gating it, so an anonymous request to the flow executor returned a working signup form on the public internet, and the user-write stage creates accounts active, internal, and auto-added to a group. That is now genuinely invitation-only: an invitation stage with continue_flow_without_invitation false, bound ahead of the prompt so a stranger is refused before being handed a form. Proven in a real browser both ways, no token gives access-denied with zero fields, a valid token gives the signup prompt. single_use is consumed when the link is OPENED, not when signup completes. Proved with an A/B: single-use vanished after one open, reusable survived a second. Messaging apps prefetch URLs to build link previews, so texting single-use links would burn most of them before anyone tapped one, and it would present as "your link is broken". Links therefore default to reusable-until-expiry, with single-use as a deliberate checkbox and the reason written beside it. Other things this enforces rather than merely documents: - Inviting is blocked while any application still admits every authenticated account. That sequencing mistake is invisible when made and expensive afterwards. - The username is pinned into the invitation's fixed data, so an invitee cannot claim a different identity than the one they were invited as. - Someone who already has an account is skipped with a reason rather than double-invited. - A roster line that cannot be parsed is reported, never silently dropped; a skipped line means somebody never gets invited and nobody finds out until they ask why. Also: parseRoster started life exported from +page.server.ts, which SvelteKit rejects at request time. It typechecked and linted clean and 500'd on first load. It lives in the invitations module now, where it belongs and where it is tested. 64 tests. check, test, manypkg, typesync:check, lint:knip and lint:knip:prod all green. lint:impeccable reports 9 findings, all inside apps/storybook/storybook-static, a gitignored build artifact, pre-existing and untouched here.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First slice of PetalNet Identity, running against the live Authentik on
10.10.10.14.Not a nicer Authentik UI. It reports three separate states for every access relationship and treats their disagreement as the product:
Authentik stays the authentication source of truth. Nothing here reimplements sessions, MFA or OAuth, and every call in this slice is a
GET.What it does today, on real data
The "reaches" number is not group membership. It is Authentik's own answer for that named person against every application.
The Evaluated layer
check_access/?for_user=<pk>gives a truthful per-person answer for every SSO application, with no connector work and no impersonation. That is weaker than probing the application itself and much stronger than reading configuration and hoping, so it is labelled as its own state rather than being passed off as a probe. Real probes and local-account discovery are the next slice, and they are the only way to see a local-auth bypass.Resolver
Deny beats allow at every distance:
explicit-direct-deny→inherited-deny→direct-allow→inherited-allow→default-deny. If allow could win by being nearer, an exception carved high in the tree would be silently defeated by any leaf grant.Breadth-first role walking, so a role reachable both directly and through a long chain counts as direct. Cycle detection is a separate depth-first pass, because BFS with a
seenset cannot distinguish a cycle from a diamond, and a diamond is legal. Cycles throw with the cycle named, and are caught even in a branch nobody holds yet.Verification
27 tests, including the design's own worked examples: "why can Parker access Grafana", "why can Cooper not access Paperless", and the
give Cooper Friendspreview computing gains PetalNotes / keeps Jellyfin and Glovebox / still denied Grafana, Paperless, Dawarich.The suite has teeth, proven rather than assumed. Deleting the
detectCyclescall turns exactly four tests red; restoring it turns them green.Rendered against live Authentik after every refactor, not just typechecked.
Three defects found by running it
BROKEN_ACCESSfindings out of network errors.unknownis now its own state and never counts as deny; a failed read of Authentik renders a loud error rather than an empty dashboard that reads as a clean bill of health.knip and lint findings, fixed at the root
knip was right on all of it, so nothing is suppressed:
subjectsWithAccessduplicatedwhoReaches. Two functions answering "who can reach this" is how two answers start disagreeing — deleted.reconciliationFindingswas written, then never called, while the load function already held exactly the evidence it needs. Now wired up, so three-state disagreements appear in the findings list.orphanIdentityFindings,mfaFindings,isMachineneeded data this slice does not collect. Speculative API is dead code — removed; they return with the connector work, written against real data.resolve.tsshrank toexplainAccessandrenderExplanation. Tests now drive the public API, so they assert on behaviour a caller can observe.state.tswas fetching/core/groups/and never reading it — a wasted round trip to the lab's authentication service..sort()→toSorted(); two of them were sorting arrays that get returned.no-await-in-loop, both restructured as recursion rather than waived. In the worker pool and in pagination the sequencing is inherent, and recursion states that instead of hiding it in a loop body.check,lint:impeccable,test,manypkg,typesync:check,lint:knip,lint:knip:prodall pass on this app.Deployment
Needs
AUTHENTIK_URLandAUTHENTIK_TOKEN. Both are required with no defaults — the app returns a 500 naming the missing variable rather than rendering an empty dashboard.Not built yet, deliberately
Jellyfin discovery, the Migration Center, real access probes, bulk invite, rollback, the graph view and the command palette. Reconcilers with 22 connectors are where this class of project dies; the gate and the explanation engine come first so that filling in accounts is a safe operation, not just a fast one.