IaC Bazaar

Identity and access

Vizier Central signs people in through the identity provider you already run, and takes access away through the governance system you already run. This page is written for the person who has to sign that off: what the deployment holds, what it refuses, and where the boundaries are. It is not the configuration walkthrough.

What has been tested, and what has not

The four protocols are implemented against their specifications and exercised by tests that drive the real handlers: a forged SAML signature, a replayed assertion, an ID token carrying the wrong nonce, a provider that cannot do PKCE S256, an LDAP URL that would send a password in the clear. What has not happened is a production directory. No Entra ID tenant, no Okta organisation and no corporate LDAP has signed anybody in through this code yet. That is a fact worth having before a pilot rather than after one, and it is why the first integration is something to schedule with us rather than to assume.

Four ways in

A deployment can enable any combination of these, per organisation. They differ entirely in how they establish who somebody is, and not at all in what happens afterwards.

Password
local

A credential Central holds itself, hashed with PBKDF2 at 600,000 iterations. It exists for the first owner and for deployments with no directory, and a deployment can run without it.

OIDC
browser

The authorization code flow with PKCE. Entra ID, Okta, Auth0, Keycloak, Google Workspace and Ping are all OpenID Connect, and there is one implementation with no per-vendor branch.

SAML 2.0
browser

Service-provider-initiated. The assertion signature is checked, the response must answer a request this deployment actually made, and an assertion is accepted once.

LDAP and Active Directory
direct bind

A bind against your directory over ldaps://, or ldap:// with StartTLS. A plaintext bind is refused rather than warned about, because an LDAP simple bind sends the password as a plain octet string.

The three federated methods converge on one function that decides whether somebody gets in and with which role. Nothing else in the product makes that decision, which matters to a reviewer for a plain reason: the paragraph you have to read to know who can reach production is one paragraph, in one place, rather than three near-copies with one of them subtly different.

Everything that belongs to being signed in is written once as well - the cookie, its lifetime, the cross-site check, the audit line, revocation - so the four doors cannot drift apart on the parts that decide how long access lasts.

What the database holds

A provider's configuration is a row. It carries the issuer, the client id, the claim names, the LDAP base DN, the SAML entity id and the identity provider's signing certificate, which is public by definition. It carries no secret. The OIDC client secret and the LDAP bind password are named, not stored: the configuration says which environment variable holds the value, and the process reads it from there.

an OIDC provider, as it is stored
{
  "kind": "oidc",
  "name": "Entra ID",
  "config": {
    "issuer":        "https://login.microsoftonline.com/<tenant>/v2.0",
    "clientId":      "8f2c41a9-db7e-5630-c8a4-17fd2e9b05c3",
    "secretEnv":     "VIZIER_IDP_ENTRA_CLIENT_SECRET",
    "groupsClaim":   "groups",
    "allowJit":      false,
    "requiredGroup": "vizier-users",
    "defaultRole":   ""
  }
}

secretEnv is the NAME of an environment variable. The client secret is
not in this row, not in this table, and not in a backup of either.

Two consequences follow, and they are the two a reviewer is checking for. Rotating a client secret is an edit to the environment and a restart, with the database untouched. And a database backup handed to somebody debugging contains configuration and nothing that would let them sign in as anybody. That is the same promise Central already makes about cloud credentials, which it also does not hold.

Just-in-time provisioning is off by default. An Entra tenant or an LDAP directory usually contains every employee in the company, and most of them have no business in a control plane that can destroy production. With it off, an administrator or your provisioning system creates the account first and a sign-in only links to it. Turning it on is deliberate, and it can be narrowed to a list of email domains. requiredGroup is the blunter control: set it, and anybody outside that group is refused whatever else their groups say.

Groups to roles

Your directory says which groups somebody is in. An owner, in Central, says what a group means. The mapping is a table of group name to Vizier role, and the path from “an IdP administrator adds somebody to a group” to “that person can apply to production” always passes through a decision somebody made here.

A person in several mapped groups gets the strongest role any of them maps to, not the first one the database happened to return. Somebody in both vizier-viewers and vizier-admins is an admin, and their permissions do not depend on something neither they nor their administrator can see. Matching is case-insensitive, because Active Directory group names are, and an operator who capitalises a group name differently in two places has not made a security decision. A mapping written for one provider beats a mapping that applies to all of them at the same strength, so one directory can be treated differently without deleting the general rule.

When no group matches and the provider sets no default role, the sign-in is refused. That is the deliberate direction: quietly landing an unmapped person on viewer looks like a working integration while the mapping is broken, and nobody finds out until somebody needs a permission they should have had.

One writer per account, never two. A federated sign-in re-derives the role from the current groups every time somebody signs in, which is what makes a removal from the admin group take effect rather than leaving admin in place until a human notices. It deliberately does not touch an account your provisioning system manages, and the provisioning side is the mirror image: it acts only on the accounts it manages. Two writers with different opinions is how a role flaps on every sign-in and how an audit trail stops being able to say who granted what.

SCIM and your IGA system

Central serves SCIM 2.0 at the paths RFC 7644 fixes, because an identity system is configured with a base URL and appends /Users itself. SailPoint, Saviynt, Okta and Entra ID all speak it. Users and Groups support create, read, replace, patch and delete, with filtering, pagination and the discovery documents an onboarding wizard reads before it will go on.

what you paste into the IGA system
base URL:   https://vizier.your-company.internal/scim/v2
auth:       Bearer <an API token labelled scim, or scim:sailpoint>

GET /scim/v2/ServiceProviderConfig
GET /scim/v2/Users?filter=userName eq "[email protected]"
PATCH /scim/v2/Users/{id}   {"Operations":[{"op":"replace",
                              "path":"active","value":false}]}

The provisioning credential is restricted twice over, and both checks have to pass. It must belong to an owner, because provisioning creates accounts and decides what they may do. And it must be a token minted for this purpose, carrying the label scim or scim:<system> - so an ordinary API token found in a build log cannot deprovision your organisation, and so revoking one integration does not stop the others. A browser session is refused outright on this surface.

What SCIM may and may not decide. It says who exists and who is in which group. It does not say what a group means, and it cannot assert a role: RFC 7643 defines a roles attribute and an identity system may well send one, which is read back on the way out so an auditor can see it and ignored on the way in. It also never confers or removes owner. An integration credential, on its own, cannot manufacture privilege.

A deactivation that would leave the organisation with no enabled owner is refused. An identity system that has decided the last owner is a leaver is describing an organisational problem, and answering it with a lockout does not help.

What deactivation cuts

This is the part worth reading closely, because it is where most tooling stops one step short. Deactivating somebody in your identity system cuts their browser session and their API tokens, in the same operation. The credential sitting in a CI job that nobody remembers configuring stops working at the same moment their console session does.

Three things happen, in this order:

  • 1. The account is disabled. Every read that resolves a credential to a person carries that condition, so the very next request is refused. This alone is immediate and total, and it is also not enough.
  • 2. Every session is revoked.
  • 3. Every API token is revoked.

Steps 2 and 3 look redundant next to step 1, and a reviewer should ask. The moment they stop looking redundant is reactivation: a contractor returning, a leaver rehired, an identity system that deactivates and reactivates around a role change. Without them, switching the account back on hands back the browser session that was open when they left and the token pasted into a pipeline two years ago. Deprovisioning that a later provisioning event can undo is not deprovisioning. Reactivation restores the account and nothing else: coming back is a reason to be issued new credentials, not to inherit revoked ones.

Disabling comes first on purpose. If a revocation below it fails, the account is already refused everywhere and the error sends the identity system back to retry - and every step is idempotent and keeps the first timestamp, so a retry finishes the work without moving the evidence of when access actually stopped.

The audit log records the effect rather than the intent, because “we deactivated them” and “we revoked the four credentials they held” are different claims and only the second is worth anything to the person running the campaign:

audit log
scim.user.deactivate  [email protected]
  active=false by scim:entra; revoked 2 session(s)
  and token(s) vzc_a996ec, vzc_71b0d4

A token prefix is the only part of a token's plaintext this database ever held, and it is what lets somebody match this line to a credential they found in a log.

A lapsed invoice cannot trap a leaver inside your organisation. Creating an account over SCIM needs a current licence and answers 402 without one. Deactivating a user, deleting a group and removing somebody from one keep working regardless. The asymmetry is deliberate: a kill switch that a billing problem can withhold is not a kill switch.

Sessions are rows

A session is a row in your Postgres, read on every request. It is not a JWT and not a signed cookie, and that is the choice everything above depends on. A self-describing token is a claim the server cannot take back before it expires, and “access stops within twenty minutes” is not the promise an identity governance integration is making. A row read on every request stops it on the next one, at the cost of one indexed lookup.

Sessions expire twelve hours after they are created however active they are, and sixty minutes after they were last used. The idle timeout is applied when the row is read rather than baked into an expiry column, so shortening it takes effect on sessions that already exist - an operator tightening that after an incident means it to apply now.

The database stores SHA-256 of the session token and of the cross-site token, never either value, the same discipline API tokens follow. A signed-in person can list their own sessions with the device and address each was created from, and revoke one. The session they are currently using is marked, so nobody signs themselves out by accident and then wonders why.

Cross-site protection applies where it can do something: a non-GET request authenticated by the cookie has to echo an X-Vizier-CSRF header the console was handed at sign-in, and it is not itself a cookie, because a value the browser attaches automatically cannot prove the request came from our own page. A request authenticated by a bearer token needs no such header - nothing attaches an Authorization header on a cross-site form post, so there is nothing to prove.

What a refusal reveals

A password refusal says the same thing whether the address is unknown, the account is disabled, the account has no password set, or the password is simply wrong. All four cost the same work as well: the verification runs whatever the lookup found, burning the same 600,000 iterations against a throwaway salt for an address that does not exist. An unknown address refused in a microsecond while a real one takes 60ms has answered “does this person work here”, and enumerating staff is the first step of a credential-stuffing run.

Which of the four it was goes into the sign-in attempt log for the operator, where it belongs, and never onto the wire. Repeated failures restrict further attempts per account and address, and if that counter cannot be read the door fails closed and says so with a 503 rather than answering like a wrong password - a client spraying passwords can cause a struggling database, and “the counter timed out” must not become a way to switch the counter off.

A federated refusal is allowed to be more specific, because by then the person has already proved who they are to your identity provider and there is nothing left for them to enumerate. Somebody whose groups map to nothing is told exactly that, and told to ask an administrator to map one of the groups they are in, which is the difference between a five-minute fix and a support ticket.

Access reviews

A certification campaign asks one question - who still has access, and prove it - and will not accept a screenshot. Central answers it as a file with a date on it, as JSON or CSV, readable by an owner.

the export
$ curl -H "Authorization: Bearer $TOKEN" \
    https://vizier.your-company.internal/api/v1/access-review > review.json

$ curl -H "Authorization: Bearer $TOKEN" \
    https://vizier.your-company.internal/api/v1/access-review.csv > review.csv

email,name,role,active,roleSince,roleFrom,groups,grants,scimManaged,
externalId,createdAt,lastSignInAt,activeSessions,hasPassword,
federatedIdentities,liveTokens,tokens

Per person: the role they hold, since when, whether it is explained by a group or was assigned in Vizier directly, which groups they are in and which of those confer something, when they last signed in, how many sessions are live, and every credential they hold - each API token by label and prefix, with when it was last used and whether it is revoked. Never the credential itself.

Two details a reviewer tends to probe. When the audit log does not reach back far enough to say when a role was granted, the field is absent rather than zero: a role whose grant date is unknown and a role granted at the epoch are opposite answers and only one of them is honest. And any failed read fails the whole export rather than returning the rest, because a partial access review is worse than none - it looks complete. Fields are also written so a leading =, +, - or @ cannot execute as a formula. Group names and token labels are chosen outside this deployment, and an access review is a file somebody opens in a spreadsheet by definition.

For your security reviewer

The short version, for the section of a questionnaire this usually lands in.

  • You host it. Your network, your Postgres, your TLS termination. It binds loopback by default, so putting it on a network is a decision somebody makes rather than one they inherit.
  • No provider secrets in the database. Configuration names an environment variable; the value is read from the process environment.
  • No cloud credentials and no Terraform state either. A cloud account here is a pointer to an identity you already control. See what Central does not hold.
  • SAML signature validation cannot be switched off. There is no configuration path that disables it, because a knob like that is one somebody sets while debugging and forgets. The identity provider's entity id is required rather than optional, since an empty one silently converts an issuer check into no check.
  • OIDC requires PKCE with S256. A provider offering only a plain challenge is refused rather than accommodated, and the ID token is bound to the request that asked for it.
  • LDAP will not send a password in the clear. ldaps://, or ldap:// with StartTLS. Anything else is refused at configuration time and again at connect time.
  • Revocation is immediate, everywhere. Sessions and API tokens are rows re-read on every request, so there is no cached decision and no self-describing credential to wait out.
  • Identity events land in the audit log. Who signed in and by which method, a role synchronised from groups, an account provisioned, a provider added or removed, and what a deactivation actually revoked.

Identity is part of Vizier Central, the self-hosted control plane, which is a paid build. The free download is Vizier Local - one operator on one tree, no accounts and nothing to sign in to. Roles and what each may do are written out at docs/rbac.