Authentik is in my homelab because I wanted single sign-on in front of the things running there, and it’s what a lot of homelabbers reach for when they want that. It went in for its own sake, with no thought of writing about it. Then it sat there, already running, already holding real applications, and became the obvious next provider to point this module at.
The contrast with the last provider hit me straight away. Okta’s free plan caps you at ten active users, which turns a test lab into a design exercise. Authentik has no such constraint: the instance is mine, nothing counts the users, and three hundred of them cost nothing but the calls to create them.
With that freedom I could see how little the directory was giving me. Users and groups are the part every provider in this module already does, and the part any script can be tested against anywhere. What sets Authentik apart sits above the directory: flows, policies, bindings, entitlements, providers and the outposts that serve them, plus a set of states where an object is created successfully and governs nothing at all.
This is part five of the series, after what makes seed data useful, Active Directory, Entra and Okta.
Five hundred objects on one instance
| |
| Count | |
|---|---|
| Users | 319, of which 19 are hand-designed and 300 generated |
| Groups | 98, the core nesting three deep, the bulk carrying groups with more than one parent |
| Applications / providers | 9 / 8, over OAuth2, proxy, SAML, LDAP and RADIUS |
| Flows / stages | 3 / 6 |
| Policies / bindings | 7 of five types / 11 |
| Entitlements / roles | 6 / 3 |
| Outposts / signing certificate | 3 / 1 |
| Scope mappings | 3 |
| Tokens / invitations | 3 / 3 |
| Notification rules / transports | 2 / 2 |
The seed takes six minutes and teardown around four and a half. Authentik has no batch endpoint, so every object is one call, and a small instance answers a create in a second or two. Users are created four at a time on a runspace pool, and teardown deletes every type the same way. Everything else goes one call at a time.
The two tiers behave the way they do everywhere else in the module. -Tier Core builds the designed rows in seconds and is the loop I use while the thing under test is behavior, and the bulk runs the same code against three hundred people.
Keeping the tiers isolated took some arranging. No bulk user joins Department Finance, so the payroll policy still admits one person. Every bulk contractor sits in Contractors and outside All Staff, where the deny policy expects them. If volume changed what a designed policy resolves to, the core tier and the full seed would disagree, and then neither result would mean anything.
Bulk membership is derived from the source data. A person gets their department group, the employment-type groups for their employee type, the management-level groups their title implies, and the office group for their office. The groups the data can’t decide, the resource and application access groups, take a stable sample of the population so none is empty by accident and none is everybody. An Authentik user carries their own group list, so a member costs nothing beyond the call that creates the user, and a group of 280 is as cheap to seed as a group of three.
I arranged the seed around the scripts I expect to fall over against it. An access review that reads one grant mechanism and reports a clean answer, when three are live and they disagree. An export that flattens a claim built as a nested object or a list and writes out something that looks like a type name. An inventory that counts providers and outposts without telling a record from a running one. On an instance with three undeployed outposts, that last one produces a guess dressed up as a report.
Attributes and paths for ownership
Authentik has no fixed profile schema and no organizational units. Every user and group carries free-form attributes, and every user has a path. The seed uses both for what they are: the lab attributes go into attributes with the seed tag among them, and every seeded user sits under a path of the module’s own, so a listing can filter on it and teardown can enumerate it.
A prefixed name on its own establishes nothing, so ownership needs two independent pieces of evidence wherever two exist:
| Object | Proof required |
|---|---|
| Users | under the seed path and carrying the tag |
| Groups | name prefix and the tag |
| Applications | slug prefix and the bracketed marker in the description, since applications have no attributes |
| Providers | name prefix and either no application or a seeded one |
| Policies, notification rules, transports | the prefix alone, which is all they have |
I only tolerate a single marker for the types at the bottom of that table because they’re the least likely to collide with anything real on an instance.
Objects that exist and govern nothing
This provider is built to expose one class of bug: an object that is created, returns 201, appears in every listing, and does nothing whatsoever. Authentik has several ways to produce one, and none of them announces itself.
A policy governs nothing until a binding attaches it to a target, and the target of an application binding is the application’s pbm_uuid. The object also has a pk, and both are UUIDs. Bind to the pk and the policy is created, reported, and enforces nothing.
The seed resolves every target and subject from the objects it already created, keyed the way the CSV writes them, so no row ever contains a UUID:
| |
Each target type has its own rule for what the identifier is. An entitlement has a pbm_uuid of its own. A notification rule is a policy-binding model, so its pk is the target directly.
A provider made through the API is the same problem from another direction. The admin UI selects the instance’s default property mappings when it creates a provider, and the API attaches none. A provider in that state issues tokens with no claims and assertions with no attributes, so nobody can sign in through it, and an inventory of providers shows it looking like a working one. I give every seeded provider the defaults the UI would have given it, found by the managed identifier Authentik assigns each mapping, because that identifier is stable across instances and the primary key is not:
| |
A mapping the instance doesn’t have is skipped with a verbose note, since an instance whose defaults were removed is still an instance to seed. The seeded scope mappings are added alongside the defaults and never replace them.
Then there are the states that are inert by design, seeded because a report has to survive them. An LDAP, RADIUS or proxy provider does nothing until an outpost runs it, and the three seeded outposts are created with no service connection, so nothing is deployed and an inventory has to tell a record from a running instance. One entitlement is held by nobody and another by a disabled user. One role has no holder. The password policy is bound to nothing, the finding an audit exists to surface.
Three grant mechanisms that disagree
The seeded applications run policy engine mode any, so a group bound straight to an application admits its members alongside whatever the expression policies decide. That makes the mechanisms independent, and I chose the seed rows so they disagree.
Access is granted through an expression policy, a group bound to the application, and a user bound directly. Contractors are admitted to the wiki by group and refused payroll by policy, so a report that reads one mechanism and stops gets them wrong specifically. A binding carries one subject, a policy or a group or a user, so each row is unambiguous about which mechanism it represents, and a reader has to enumerate all three to get the right answer.
Entitlements are Authentik’s per-application roles and sit on the same bindings machinery. The RBAC roles are the product’s own, granted through groups the way it intends, and one of them is reached only through a team three levels down the nesting chain.
Claims here are expressions over attributes, which makes them pleasant to test against: three scope mappings turn the lab attributes into a nested object, a list, and a scope the consent screen never mentions.
Flows and the missing switch
A flow is what people sign in with. The wrong flow made default locks a real administrator out of a real instance, and a seeding tool doesn’t get to make that mistake even once.
The seed never creates, edits or binds anything to a flow whose slug lacks the seed prefix, and it never writes to the brand, so no seeded flow becomes the default for anybody. A seeded flow is reachable only through a seeded provider, at the field its designation dictates, through its own URL, or tied to a seeded invitation. A test pins both rules by asserting that no request ever reaches the brand or an unprefixed flow, and that the command has no switch that would let it.
This is the same design as the Entra rule that a Conditional Access policy can never be made to enforce. With no parameter to set, a CSV typo or a hurried -Force has nothing to reach.
The three flows are the shapes a review has to handle. A sign-in flow whose second factor is skipped when the user has none, attached to the SAML and proxy providers. An authorization flow with consent that expires after four weeks, on the expenses client. An enrollment flow that only refuses, tied to the reusable invitation.
Authentik’s cascades force the teardown order. Flows go after the providers that held them, because a provider’s authorization flow cascades and deleting the flow first takes the provider with it. Stages go after the flows that bound them.
Settings in one cell
A password policy has a minimum length. A GeoIP policy has a country list. A reputation policy has a threshold that can be negative. A CSV with a column per setting would be mostly empty, so AuthentikPolicies.csv carries a Type and a Settings cell:
| |
Each value is sent typed, because the API rejects a threshold that arrives as a string:
The -? in that pattern is for the reputation policy, whose threshold is -5. Commas separate nothing in the cell, so an error message can contain one, and | separates the items of a list, with each item typed the same way.
Each policy type has its own endpoint for create and update and one shared endpoint for listing, the same shape the stages take. A re-run refuses to change an existing policy into a different type, because deleting and recreating it would take the bindings pointing at it along too.
Expiry is mostly the server’s decision
Tokens took me longer to get right than anything else in the provider, because the API accepts requests it then overrules.
An instance caps an app password at its default token duration, thirty minutes out of the box, and refuses a longer one outright. The server assigns an API token’s expiry whatever the request says. The seed’s expiring tokens are therefore app passwords with ExpiresInMinutes under thirty, and the already-expired one is an app password with a negative value, which Authentik accepts as written.
One more rule lives in the same place: a PATCH to a token that omits user reassigns the token to the caller. The seed always sends the owner when it updates one. On a run authenticated as the service account, leaving it out would hand every seeded user’s token to the service account.
No token secret is ever read. New-AuthentikToken creates the token and discards the response, since Authentik hands the secret out only through its own view_key endpoint and nothing in the seed needs it. The result carries the identifier, owner, intent and expiry. The single exception is the service account’s own token, read once at bootstrap.
Invitations go the other way, and they changed how I think about testing expiry. Authentik hides an expired invitation from every listing and purges it, so one seeded already expired would be invisible to the report and to teardown alike. The stale invitation in the seed is stale by being too long-lived instead: a year to run, for a hire that fell through months ago.
The role Authentik hides
Some users get a role of their own that nothing else ever names. Every outpost’s service account has one.
| |
Deleting the outpost deletes its service user and leaves the role behind, unmanaged. My earlier teardown removed only what it had created by name, and it left three of those roles on the instance per run, piling up in a place nobody looks. The sweep now finds each seeded user’s role by the name its primary key dictates and removes it first. It does the same for the service account before deleting the account, because by that point the account is the credential the session is running on.
The ordering constraint reads as fussy until you try it the other way round.
The service account is a superuser
New-TestServiceApp creates a service_account user and replaces the app-password token the creation call hands back with a non-expiring api-intent token, since only the latter is accepted as a bearer credential. It then adds the account to the instance’s superuser group, tests the token by calling the API with it, and writes the record that -ServiceAccount reads on every later run.
Authentik’s RBAC could scope it more narrowly. A lab account that creates and deletes users, groups, applications, providers, policies, flows and outposts needs most of the instance anyway, and that adds up to a superuser, so I made it one. In every respect but one the account is a seeded user: teardown keeps it unless you pass -RemoveServiceAccount, since it’s the credential doing the tearing down.
The SAML provider needs a keypair to sign with. Borrowing the instance’s would sign lab assertions with a real key and leave teardown nothing it could remove, so the seed asks Authentik to generate a self-signed one named with the prefix and reuses it on every run. The private key never leaves the instance. The RADIUS shared secret is generated at creation and appears nowhere in the repository.
Reading the instance back
Test-TestEnvironment reads the users, groups and applications the module owns and compares them with the seed files: every seeded name present, nothing owned that the data doesn’t describe, every display name equal by codepoint, every membership the data lists in place. The shared set of hard names is here too, each on a plain ASCII username.
The report resolves the bindings for each target and records who ends up granted, so an application’s row lists its subjects in place of a binding count, and the three mechanisms appear side by side. It also normalizes timestamps on the way through. PowerShell 7 hands an ISO timestamp back from JSON as a DateTime and 5.1 leaves it a string, and a token expiry column that sorts as text is worse than none.
Repair-TestEnvironment re-runs the seed steps that own whatever failed, and Compare-TestEnvironment lines this instance’s people up against another connected provider as described for PingOne. The nineteen designed people are the same nineteen the Entra, FreeIPA and PingOne providers hand-design, mapped from Core/Data/SeedPeople.csv.
Providers\Authentik\Tools\New-AuthentikTestSeedData.ps1 regenerates the bulk tier, mapping the AD provider’s people across so the same population exists in both. Everything derived comes from a stable SHA-256 hash of the object’s own key, so regenerating produces byte-identical files.
Authenticator devices
These are the one thing I couldn’t seed. The admin endpoints for TOTP, static and WebAuthn devices create them for the caller only, and the owner field is read-only, so the API offers no way to put MFA state onto a seeded user. The sign-in flow’s second factor is seeded as optional for that reason, and it skips for every seeded user, matching an instance where nobody has enrolled.
Day-to-day runs
-Skip and -Keep take the same step names, so the access layer can be rebuilt over an existing directory without re-seeding three hundred people, and -Tier Core gives the designed rows alone in seconds. The full command surface, with every object type and the gotchas in longer form, is on the provider’s page: Providers/Authentik/README.md.
I only point it at instances I can afford to rebuild. Everything above holds against a stock Authentik with its defaults intact, the only configuration I have tested it against.
