Authentik is in my homelab because I wanted single sign-on in front of the things running there, and it is 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.

Which made the contrast with the last one immediate. Okta’s free plan caps you at ten active users, turning a test lab into a design exercise. Authentik removes the constraint entirely: the instance is yours, nothing counts your users, and three hundred of them cost nothing but the calls to create them.

Having that freedom made it obvious 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 makes Authentik itself different from the others sits above the directory: flows, policies, bindings, entitlements, providers and the outposts that serve them, and 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.

What lands on the instance

1
2
3
4
5
6
$token = Read-Host 'API token' -AsSecureString
Connect-TestEnvironment -Provider Authentik -BaseUrl https://auth.example.com -ApiToken $token
New-TestServiceApp

Connect-TestEnvironment -Provider Authentik -BaseUrl https://auth.example.com -ServiceAccount
New-TestEnvironment -ShowProgress
Count
Users319, of which 19 are hand-designed and 300 generated
Groups98, the core nesting three deep, the bulk carrying groups with more than one parent
Applications / providers9 / 8, over OAuth2, proxy, SAML, LDAP and RADIUS
Flows / stages3 / 6
Policies / bindings7 of five types / 11
Entitlements / roles6 / 3
Outposts / signing certificate3 / 1
Scope mappings3
Tokens / invitations3 / 3
Notification rules / transports2 / 2

About five hundred objects, and the seed takes six minutes with 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 and, at teardown, every type is deleted four at a time on a runspace pool; 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 to use while the thing under test is behaviour, and the bulk proves the same code on three hundred.

The isolation between the tiers is deliberate and took some arranging. No bulk user joins Department Finance, so the payroll policy still admits exactly one person. Every bulk contractor sits in Contractors and outside All Staff, where the deny policy expects them. Volume that quietly changed what a designed policy resolves to would make the core tier and the full seed disagree, and then neither result means anything.

Membership in the bulk is derived from the source data and not sampled: a person’s department group, the employment-type groups for their employee type, the management-level groups their title implies, the office group for their office. The groups nothing in the data can 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.

Three kinds of script fall over against a lab like this, and the seed is arranged around them. 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 something that looks like a type name. And an inventory that counts providers and outposts without distinguishing a record from a running one, which on an instance with three undeployed outposts is the difference between a report and a guess.

No schema, no containers, and two pieces of evidence

Authentik has no fixed profile schema and no organisational units. Every user and group carries free-form attributes, and every user has a path. The seed uses both as 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 name carrying the prefix is not proof of anything, so ownership needs two independent pieces of evidence wherever two exist:

ObjectProof required
Usersunder the seed path and carrying the tag
Groupsname prefix and the tag
Applicationsslug prefix and the bracketed marker in the description, since applications have no attributes
Providersname prefix and either no application or a seeded one
Policies, notification rules, transportsthe prefix alone, which is all they have

The types at the bottom of that table are the ones least likely to collide with anything real on an instance, which is the only reason a single marker is tolerable for them.

Objects that exist and govern nothing

The class of bug this provider is built to expose is 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, not its pk. Both are UUIDs on the same object. Bind to the wrong one 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Every target and subject the CSV can name, resolved once. Each map is keyed the way the
# CSV writes it, so a row never sees a UUID.
foreach ($application in (Get-AuthentikSeededObject -Type Applications -Connection $connection)) {
    $key = $application.slug.Substring($marker.SlugPrefix.Length + 1)
    $targets["app:$key"] = [string]$application.pbm_uuid
}
foreach ($entitlement in (Get-AuthentikSeededObject -Type Entitlements -Connection $connection)) {
    if ($entitlement.attributes.PSObject.Properties['labKey']) { $targets["entitlement:$($entitlement.attributes.labKey)"] = [string]$entitlement.pbm_uuid }
}
foreach ($rule in (Get-AuthentikSeededObject -Type NotificationRules -Connection $connection)) {
    # A notification rule is itself a policy-binding model, so its pk is the target.
    $targets["rule:$($rule.name.Substring($marker.Prefix.Length))"] = [string]$rule.pk
}

Three target types, three different rules 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 shape of problem from a different direction. The admin UI selects the instance’s default property mappings when it creates one; the API attaches none. A provider in that state issues tokens with no claims and assertions with no attributes, so nothing can sign in through it, and an inventory of providers shows it looking exactly like a working one. Every seeded provider is given the defaults the UI would have given it, found by the managed identifier Authentik assigns each mapping, since that is stable across instances where the primary key is not:

1
goauthentik.io/providers/oauth2/scope-openid

A mapping an instance does not have is skipped with a verbose note and not an error, because an instance whose defaults were removed is still an instance to seed. The seeded scope mappings are added alongside the defaults and never in place of 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 ways to get access, chosen to 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 the seed rows are chosen so they disagree.

Access is granted three ways on purpose: by expression policy, by a group bound to the application, and by 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 is wrong about them specifically. A binding carries exactly 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 are expressions over attributes here, a pleasant thing 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 switch that is not there

A flow is what people sign in with. The wrong flow made default locks a real administrator out of a real instance, and that is not a mistake a seeding tool gets to make once.

Two things are therefore true by construction. 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, or through its own URL, or tied to a seeded invitation. A test pins both by asserting that no request ever reaches the brand or an unprefixed flow, and that the command carries no switch that would let it.

Same shape as the Entra rule that a Conditional Access policy can never be made to enforce: the safety is the absence of a parameter, so there is nothing for a CSV typo or a hurried -Force 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 enrolment flow that only refuses, tied to the reusable invitation.

Teardown order is forced by Authentik’s cascades. 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:

1
length_min=12;check_zxcvbn=TRUE;countries=US|GB

Each value is sent typed, because the API rejects a threshold that arrives as a string:

1
2
3
4
5
6
7
8
$typed = {
    param([string]$value)
    $trimmed = $value.Trim()
    if ($trimmed -eq 'TRUE') { return $true }
    if ($trimmed -eq 'FALSE') { return $false }
    if ($trimmed -match '^-?\d+$') { return [int]$trimmed }
    return $trimmed
}

The -? in that pattern is there for the reputation policy, whose threshold is -5. A comma is not a separator anywhere 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 turn an existing policy into a different type rather than deleting and recreating it, since the bindings pointing at it would go with it.

Expiry is mostly the server’s decision

Tokens took 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. An API token’s expiry is assigned by the server whatever the request says. So the seed’s expiring tokens are app passwords, their ExpiresInMinutes stays under thirty, and the already-expired one is an app password with a negative value, accepted exactly 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, and on a run authenticated as the service account that is the difference between updating a seeded user’s token and quietly collecting it.

No token secret is ever read. New-AuthentikToken creates 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 taught me something about testing expiry at all. 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, and the hire it was made for 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Authentik gives some users a hidden role of their own, named ak-managed-role--user-<pk>,
# to carry object permissions: every outpost's service account has one, for instance.
# Deleting the user does not delete the role, and nothing else ever names it, so for a
# user the seed owns it is ours by construction and is removed before the user is. Before,
# because for the service account the user is also the credential this session runs on.
$removeManagedRole = {
    param($userList)
    if ($userList.Count -eq 0) { return }
    $wanted = @{}
    foreach ($u in $userList) { $wanted['ak-managed-role--user-{0}' -f $u.pk] = $u.username }
    $roles = @(Invoke-AuthentikRequest -Method GET -Path '/rbac/roles/' `
            -Query @{ search = 'ak-managed-role--user-' } -Connection $connection -Paginate |
            Where-Object { $wanted.ContainsKey([string]$_.name) })

Deleting the outpost deletes its service user and leaves the role behind, unmanaged. A teardown that removed only what it had created by name left three of those on the instance per run, accumulating quietly in a place nobody looks. The sweep finds each seeded user’s role by the name its primary key dictates and removes it first, and does the same for the service account before the account itself, 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.

A superuser, and no pretending otherwise

New-TestServiceApp creates a service_account user, 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, adds the account to the instance’s superuser group, proves 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 a superuser is what that amounts to. The account is a seeded user in every respect but one: teardown keeps it unless you pass -RemoveServiceAccount, since it is the credential doing the tearing down.

The SAML provider needs a keypair to sign with, and borrowing the instance’s would sign lab assertions with a real key and leave teardown nothing it could remove. 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.

Checking it afterwards

Test-TestEnvironment reads the users, groups and applications the module owns, the same way teardown finds them, and compares them with the seed files: every seeded name present, nothing owned that the data does not describe, every display name equal by codepoint, every membership the data lists in place. Names are compared ordinally, because the nine people written in Han, Cyrillic, Greek, Arabic and Devanagari are here too, each with a plain ASCII username, and an instance that mangles one of them would pass a -eq comparison against the wrong string.

The report resolves the bindings for each target and records who ends up granted, so an application’s row shows its subjects rather than a count of bindings, and the three mechanisms appear side by side where a single-mechanism reader would have seen one of them. It also normalises timestamps on the way through, since PowerShell 7 hands an ISO timestamp back from JSON as a DateTime and 5.1 leaves it a string, and a token expiry that sorts as text is worse than no expiry column at all.

Repair-TestEnvironment re-runs the seed steps that own whatever failed, and Compare-TestEnvironment matches this instance’s people against another connected provider’s by their shared login key. The nineteen designed people are the same nineteen the Entra, FreeIPA and PingOne providers hand-design, mapped from Core/Data/SeedPeople.csv, so a name cannot drift between directories without the comparison saying so.

The bulk tier is regenerated by Providers\Authentik\Tools\New-AuthentikTestSeedData.ps1, which maps 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, and a test regenerates them into a temporary folder and fails if the committed copies have drifted.

What is deliberately missing

Authenticator devices. The admin endpoints for TOTP, static and WebAuthn devices create them for the caller only, and the owner field is read-only, so there is no way through the API 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.

Running it

1
2
3
4
5
Connect-TestEnvironment -Provider Authentik -BaseUrl https://auth.example.com -ServiceAccount
New-TestEnvironment -ShowProgress
Get-TestEnvironmentReport
Test-TestEnvironment
Remove-TestEnvironment -WhatIf

-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.

Point it at an instance you can afford to rebuild. Everything above holds against a stock Authentik with its defaults intact, the only configuration I have tested it against.

Next in the series: FreeIPA, the largest provider in the module and the one that taught me the most, because a POSIX directory with Kerberos manages things none of the cloud providers have an equivalent for. HBAC, sudo rules, ID views, automount, SELinux user maps, OTP tokens, and real certificates issued by the realm’s own CA, where teardown stops deleting and starts revoking.