Skip to content

feat(identity): PetalNet Identity, first slice - #399

Open
returnsvoidjanet wants to merge 4 commits into
mainfrom
feat/petalnet-identity
Open

feat(identity): PetalNet Identity, first slice#399
returnsvoidjanet wants to merge 4 commits into
mainfrom
feat/petalnet-identity

Conversation

@returnsvoidjanet

Copy link
Copy Markdown
Contributor

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:

  • Intended — what the role graph says should be true
  • Configured — what Authentik's bindings say
  • Evaluated — what Authentik answers when asked about a specific 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 today, on real data

792 access checks, verified 15s ago in 14.5s
Creating a person is blocked — 1 application currently permits every authenticated account: ldap
Findings: 1 — UNBOUND APPLICATION, PetalNet LDAP
People: 11, with roles and how many applications each actually reaches
Applications: 72, what each is bound to, and who reaches it

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-denyinherited-denydirect-allowinherited-allowdefault-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 seen set 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 Friends preview computing gains PetalNotes / keeps Jellyfin and Glovebox / still denied Grafana, Paperless, Dawarich.

The suite has teeth, proven rather than assumed. Deleting the detectCycles call 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

  1. 36-second page. It asked Authentik about all 792 person-application pairs sequentially. A worker pool got 15 seconds, and that was still the wrong fix — the error was treating a reconciliation cycle as a rendering step. It now runs off the request path and the page reads the cached result in ~15ms. The header states the check count, duration and age, which is the evidence model the design wanted anyway: a result verified 15 seconds ago and one verified 19 days ago are different claims.
  2. A failed check was recorded as a denial. That manufactures BROKEN_ACCESS findings out of network errors. unknown is 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.
  3. "1 application currently permit" — plural on the noun, not the verb, on the one screen whose whole job is to be believed.

knip and lint findings, fixed at the root

knip was right on all of it, so nothing is suppressed:

  • subjectsWithAccess duplicated whoReaches. Two functions answering "who can reach this" is how two answers start disagreeing — deleted.
  • reconciliationFindings was 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, isMachine needed data this slice does not collect. Speculative API is dead code — removed; they return with the connector work, written against real data.
  • The public surface of resolve.ts shrank to explainAccess and renderExplanation. Tests now drive the public API, so they assert on behaviour a caller can observe.
  • state.ts was fetching /core/groups/ and never reading it — a wasted round trip to the lab's authentication service.
  • Six .sort()toSorted(); two of them were sorting arrays that get returned.
  • Two 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:prod all pass on this app.

lint:impeccable reports 34 anti-patterns repo-wide, all inside apps/storybook/storybook-static, a gitignored build artifact. Pre-existing and unrelated; flagging rather than fixing.

Deployment

Needs AUTHENTIK_URL and AUTHENTIK_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.

Janet 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant