Documentation
¶
Overview ¶
derive.go holds the IDENTITY-DERIVATION seam: everything that turns an ID token's claims into what a Wardyn session carries about WHO this human is — their role (deriveRole, Config.RoleMap, the legacy operator allowlist) and their group snapshot (sessionGroups, the subject a capability grant matches).
Split out of oidc.go when the group snapshot pushed that file past the 1000-line gate. The seam is real, not arithmetic: nothing here touches HTTP, cookies, or the OAuth2 exchange. It is pure claims-in, identity-out, which is also why it is the part of this package that is unit-testable without a signed token or a fake IdP.
Package oidc implements human SSO for Wardyn via OpenID Connect (Dex-compatible).
CSRF posture (v0) ¶
CSRF protection is two-layered:
- State parameter: a random 128-bit value stored in an HttpOnly SameSite=Lax cookie ("wardyn_oidc_state") and compared to the IdP callback parameter.
- SameSite=Lax on all session cookies: protects all same-site navigations from cross-site request forgery without requiring a synchronizer token.
A PKCE code_challenge (S256) is included in the authorization request and verified by the token endpoint. This provides additional security even when the state check is bypassed (e.g. by a mix-up attack).
Known gap: the nonce is verified in the ID token but is not bound to the device (mitigated by state + PKCE). A future milestone can pin it.
Session storage ¶
Sessions live entirely in a signed HttpOnly SameSite=Lax cookie named "wardyn_session". The cookie payload is a JSON struct containing sub, email, role, and expiry, HMAC-SHA256 signed with the key passed to New. The key is never logged and never leaves the process. A cookie with no role — signed before the role field existed (pre-0.5), or otherwise carrying an empty one — decodes as NO session (see decodeSession), forcing a re-login that derives one fresh rather than granting an undefined role.
Integration ¶
The Middleware exposed here accepts either a valid session cookie (sets a HumanPrincipal on the context via a package-private key) or falls through to the next handler (which the integrator wraps with the existing adminAuth bearer path). Use PrincipalFromContext to read the principal; it returns "" when no SSO session is present so the caller can fall through gracefully.
The wardyn_session cookie codec: the signed payload's format version, and the encode/decode/HMAC trio around it. Split from oidc.go by seam (the file-size gate), exactly as context.go was; no behaviour lives here that oidc.go's package doc does not describe.
Index ¶
- Constants
- Variables
- func ASCIIOnly(s string) bool
- func CanonicalGroupSubject(s string) (string, bool)
- func EmailFromContext(ctx context.Context) string
- func ExpiryFromContext(ctx context.Context) time.Time
- func GroupsFromContext(ctx context.Context) []string
- func GroupsTruncatedFromContext(ctx context.Context) bool
- func MemberModeFromContext(ctx context.Context) bool
- func MemberPreviewNoCredential(ctx context.Context) bool
- func NameFromContext(ctx context.Context) string
- func ParseRoleMap(csv string) (map[string]string, error)
- func PrincipalFromContext(ctx context.Context) string
- func RoleFromContext(ctx context.Context) string
- func SessionRejectedFromContext(ctx context.Context) string
- func ValidRole(s string) bool
- type Authenticator
- func (a *Authenticator) AttachLoginGrantSink(sink LoginGrantSink)
- func (a *Authenticator) CallbackHandler(w http.ResponseWriter, r *http.Request)
- func (a *Authenticator) ChartRoleMap() map[string]string
- func (a *Authenticator) DefaultRole() string
- func (a *Authenticator) HasEmailDomains() bool
- func (a *Authenticator) HasOperatorEmails() bool
- func (a *Authenticator) IsOperatorEmail(v string) bool
- func (a *Authenticator) Issuer() string
- func (a *Authenticator) LoginHandler(w http.ResponseWriter, r *http.Request)
- func (a *Authenticator) LogoutHandler(w http.ResponseWriter, r *http.Request)
- func (a *Authenticator) MergedMapEmpty(rows []RoleMapping) bool
- func (a *Authenticator) Middleware(next http.Handler) http.Handler
- func (a *Authenticator) PreviewRole(ctx context.Context, roles, groups []string, email string) (role string, matched []Match, ok bool, err error)
- func (a *Authenticator) PreviewRoleAgainst(rows []RoleMapping, roles, groups []string, email string) (role string, ok bool)
- func (a *Authenticator) SetMemberMode(w http.ResponseWriter, r *http.Request, on, noCredential bool) (stampedRole string, err error)
- type Config
- type LoginGrant
- type LoginGrantSink
- type Match
- type MatchSource
- type RoleMapping
- type RoleMappingSource
- type Session
- type SessionRevocations
Constants ¶
const ( RoleAdmin = "admin" RoleSecurityAdmin = "security_admin" RoleMember = "member" )
Wardyn roles a session can carry. See Session.Role / Config.RoleMap.
RoleSecurityAdmin is the THIRD tier (0.7): a principal who governs the deployment's security posture — approvals, audit, permissions/capability grants, governance profiles — WITHOUT the super admin's reach into other people's runs, credentials or host configuration. It is NOT a rung on a monotone ladder below RoleAdmin: internal/api keeps two named predicates (isOperator = super only, isSecurityOperator = super OR security admin) precisely because security_admin ⊄ admin at the role-SNAPSHOT stamp sites (ssh keys, attach tickets), where RoleAdmin means the narrower, concrete thing "this credential reaches runs its holder does not own". A ladder would stamp admin on a security admin's SSH key — an interactive shell in every developer's sandbox. See internal/api/http.go's isSecurityOperator.
MAPPED TIER ONLY. security_admin is reachable through the merged role map (chart WARDYN_OIDC_ROLE_MAP rows plus console-managed rows) and nothing else: there is deliberately no WARDYN_OIDC_SECURITY_EMAILS twin of the operator allowlist, and cmd/wardynd REFUSES it as WARDYN_OIDC_DEFAULT_ROLE (a fallthrough tier is exactly the accident that should never grant it).
const SessionCodecVersion = 1
SessionCodecVersion is the wardyn_session payload's format version, stamped by encodeSession and REQUIRED to match by decodeSession.
It exists for one field that cannot be tolerantly decoded: GroupsTruncated. A 0.6 cookie carries no such key, so a JSON decode reads it as false — and false is the FAIL-OPEN answer (see the field). A human whose group snapshot was ALREADY truncated at their last login would keep a cookie asserting "complete" and silently shed the group-assigned governance profile walling them, which is exactly the evaporation the bit closes. Nothing in-band distinguishes "0.6 cookie" from "0.7 cookie, not truncated", so the payload gets a version and a mismatch is simply not a session.
THE COST IS ONE EXTRA LOGIN, once, for every human holding a pre-0.7 cookie — folded into the same re-login PF-12 already documents for the pre-0.6 snapshot. A mismatch behaves exactly like the empty-Role case below: Middleware falls through with no principal, the browser is bounced to sign in, and CallbackHandler mints a current cookie. Of the three options — this, tolerant-decode-false, or a permanent second guess — it is the only one that is neither a silent widening nor a permanent one.
The compare is EXACT rather than `<`: under `<`, an OLD binary in a rolling upgrade accepts a NEW cookie and reads its unknown fields as zero, which is the same fail-open this constant exists to prevent. A mixed-version rollout therefore costs logins, not containment.
EXPORTED because a hand-minted payload is otherwise unverifiable: anything that builds a session cookie without going through encodeSession — this module's own integration tests, an embedder's harness — has to stamp the current version or produce a cookie the very next request rejects.
Variables ¶
var ErrInvalidSession = errors.New("oidc: invalid session cookie")
ErrInvalidSession is returned by decodeSession when the cookie is present but tampered, malformed, or uses a different HMAC key.
var ErrNoSession = errors.New("oidc: no session cookie")
ErrNoSession is returned by decodeSession when no session cookie is present.
var Roles = []string{RoleAdmin, RoleSecurityAdmin, RoleMember}
ValidRole reports whether s is a recognized role value. Used to validate WARDYN_OIDC_ROLE_MAP entries (ParseRoleMap), console role-mapping rows (mergeRoleMaps, internal/api's /access write boundary) and WARDYN_OIDC_DEFAULT_ROLE (cmd/wardynd, at boot) — all fail closed on a typo rather than letting a garbage role value silently reach a session cookie.
Roles is the closed set, in rank order, and it is the ONE place the set is written down. ValidRole is implemented over it and the DDL parity guard (internal/db's TestClosedEnumChecksMatchConstants, role_mappings.role) reads it, so a fourth role cannot land on one side alone in EITHER direction. It used to be typed out a third time in that guard, which left it blind in the Go-widens-first direction — and that is the direction of the incident 0053 documents: ValidRole accepted security_admin while 0051's CHECK still refused it, so POST /access/mappings passed validation and then 500'd at the database. The sibling user_drives enums already derive from types.DriveBackends and friends for exactly this reason; this is the surface that did not.
A slice rather than a map so the order is stable for callers that render it; membership goes through ValidRole.
Functions ¶
func ASCIIOnly ¶ added in v0.7.0
ASCIIOnly reports whether s contains no rune above ASCII. It is exported for internal/api, whose console-managed role-map writes must refuse exactly what this package refuses at login — one implementation, so the write surface and the match surface can never disagree about what "ASCII" means.
Case-insensitive matching (ToLower/EqualFold) does Unicode case folding, under which e.g. "roſs" (U+017F) or a KELVIN SIGN "k" (U+212A) MATCHES an ASCII string — the escalating direction (same fold-escalation guard as internal/api's isOperator, applied here to the RoleMap / LegacyAdminEmails match: both are operator-authored ASCII allowlists — WARDYN_OIDC_ROLE_MAP and WARDYN_OIDC_OPERATOR_EMAILS — that a crafted non-ASCII claim must never fold onto).
func CanonicalGroupSubject ¶ added in v0.7.0
CanonicalGroupSubject canonicalizes an operator-authored group name into the EXACT string a session snapshot carries for a claim of that name, or reports ok=false when NO snapshot can ever carry it (empty/whitespace-only, or any character outside printable ASCII).
It is exported because sessionGroups is the MATCH surface and internal/api's three group-subject WRITE surfaces — a capability grant, a governance assignment, a user-drive grant — must refuse exactly what this one drops. Every one of those subject columns is matched by exact string equality against this snapshot, so a subject this function refuses is a row that can never match anyone: a DENY that protects nothing, or a group tier that counts as "assigned" while resolving to no profile. One implementation, so the two surfaces cannot drift about what a group name is.
THE ASCII GUARD RUNS ON THE RAW VALUE, BEFORE THE FOLD, and the order is the security property — the same ordering ParseRoleMap and deriveRole's own lookup loop already use, for the same reason. strings.ToLower does Unicode case mapping: KELVIN SIGN U+212A folds to ASCII 'k' and U+0130 folds to 'i', so guarding the LOWERED value lets a crafted claim "Kubernetes-admins" (U+212A) fold ONTO the operator-authored ASCII group "kubernetes-admins" and enter the snapshot as it — inheriting every grant and every governance profile bound to the real group. Fold first, guard second, and the guard is decorative.
func EmailFromContext ¶ added in v0.5.0
EmailFromContext returns the email claim of the session Middleware verified, or "" when there is no SSO session (or the IdP returned no email — which is possible whenever AllowedEmailDomains is empty, since that is the only check that requires one). It is the identity internal/api resolves the minimal viewer/operator role from; the "sub" is opaque and cannot be matched against an operator allowlist an admin can actually write down.
func ExpiryFromContext ¶ added in v0.6.0
ExpiryFromContext returns when the session Middleware verified will expire, or the zero time when there is no SSO session. W31-S1-7: there is no refresh — the session dies outright at this instant — so the console surfaces it as an advance warning instead of a surprise 401 that wipes mid-work state back to the sign-in gate.
func GroupsFromContext ¶ added in v0.6.0
GroupsFromContext returns the login-time group snapshot of the session Middleware verified — the subjects a `group` capability grant matches.
NIL AND EMPTY MEAN DIFFERENT THINGS and callers must keep them apart. Empty non-nil: this session was minted by 0.6+, the IdP sent no usable group identity, and group grants genuinely do not apply. Nil: either there is no SSO session at all, or the human is holding a PRE-0.6 cookie that predates the field — group grants cannot be evaluated for them until they log in again, which is what internal/api surfaces as groups_snapshot_stale rather than silently reporting "no groups".
Same DERIVES-not-ENFORCES split as RoleFromContext: this package carries the snapshot, internal/api decides what it permits.
func GroupsTruncatedFromContext ¶ added in v0.7.0
GroupsTruncatedFromContext reports whether the session's group snapshot is PARTIAL — sessionGroups hit the cookie byte cap and dropped entries (see Session.GroupsTruncated).
A caller must treat true exactly as it treats a nil snapshot: the group identity is not answerable, so no group-scoped decision can be made from it. Reading it as "these are all their groups" is the silent tier evaporation PF-26 names.
func MemberModeFromContext ¶ added in v0.7.4
MemberModeFromContext reports whether this session is in "view as member" mode (Session.MemberMode) — an admin who asked to be treated as a member for the rest of the session.
RoleFromContext ALREADY answers member when this is true, so authorization needs this predicate for nothing: every tier decision keeps reading the role. It exists for the two things the clamped role cannot say on its own — the console's banner ("your usual role is paused"), and the seams that must refuse rather than clamp, namely the credential MINT doors, where a member-stamped credential would be re-stamped admin at the next login and outlive the mode (internal/api/membermode.go).
func MemberPreviewNoCredential ¶ added in v0.7.5
MemberPreviewNoCredential reports whether this session is in the NO-CREDENTIAL posture of member mode — "view as a new member who has not signed in" (Session.MemberModeNoCredential, 0.7.5 field report finding 3).
It is published as `MemberMode && MemberModeNoCredential`, so it implies MemberModeFromContext and no caller has to read both.
Like MemberModeFromContext it authorizes nothing and clamps nothing: the effective role is already member, the principal is still the admin's own sub, and the ONE thing this predicate moves is whether a PER-USER model-credential read answers "absent" (internal/api's previewHidesOwnCredential). Everything that already fails closed on an absent credential — the create-time mechanism gate, dispatch, /setup/status's probe — then reaches the not-signed-in state with no second rule of its own.
func NameFromContext ¶ added in v0.7.1
NameFromContext returns the display-name ("name") claim of the session Middleware verified, or "" when there is no SSO session or the IdP sent none. Display only — internal/api shows it in the console header and nothing else reads it; the identity every decision is keyed on stays the sub (PrincipalFromContext) and the allowlist identity stays the email.
func ParseRoleMap ¶ added in v0.5.0
ParseRoleMap parses WARDYN_OIDC_ROLE_MAP: a comma-separated list of "value=role" pairs, e.g. "Wardyn.Admin=admin,eng-team=member,alice@corp.com=admin". value is matched case-insensitively against an ID token's roles/groups claims or its email (see deriveRole); role must satisfy ValidRole — RoleAdmin, RoleSecurityAdmin or RoleMember. This map is the ONLY way a session reaches RoleSecurityAdmin (see that constant's doc): the chart carries it the moment ValidRole accepts it, with no other boot knob to turn. Empty/blank input returns a nil map (role derivation disabled — Config.RoleMap's empty behavior) and no error; non-empty input that yields no usable entry (e.g. "," or a single malformed pair) is an error, never a silent nil — nil means "everyone is admin" (deriveRole), which must never be an accident.
func PrincipalFromContext ¶
PrincipalFromContext returns the human principal set by Middleware, or "" if no SSO session is present on the context. The returned value is the OIDC "sub" claim (a stable opaque identifier from the IdP).
Integration note: internal/api's principalFromRequest should call this first and fall back to the admin-token path when the result is "".
func RoleFromContext ¶ added in v0.5.0
RoleFromContext returns the EFFECTIVE Wardyn role for the session Middleware verified, or "" when there is no SSO session.
Effective, not stamped: a session in "view as member" mode (Session.MemberMode) answers RoleMember here whatever its cookie says, because contextWithPrincipal clamps it at the single origin. Nothing outside this package ever sees the stamped role, which is the point — see MemberModeFromContext below. This package only DERIVES and CARRIES the role — see CallbackHandler / deriveRole for how it is computed. Enforcing it (deciding what an admin vs a member may do) belongs to internal/api, the same split PrincipalFromContext/EmailFromContext already follow.
func SessionRejectedFromContext ¶ added in v0.6.0
SessionRejectedFromContext returns why Middleware rejected a presented session cookie on this request: "invalid_session" for a tampered/malformed cookie, "expired_session" for a valid-but-expired one, "revoked_session" for a valid cookie the revocation store has cut off, or "session_revocation_unavailable" when that store errored (fail-closed) — the last two only when Revocations is wired. Returns "" when no session cookie was presented at all (the ordinary non-browser-client case) or the session decoded fine.
Types ¶
type Authenticator ¶
type Authenticator struct {
// contains filtered or unexported fields
}
Authenticator provides OIDC login, callback, logout, and session-check handlers.
func New ¶
New constructs an Authenticator by performing OIDC discovery against cfg.IssuerURL. hmacKey is the secret used to sign session cookies; it must be provided by the caller (e.g. loaded from the secret store). The key is never logged.
func (*Authenticator) AttachLoginGrantSink ¶ added in v0.7.10
func (a *Authenticator) AttachLoginGrantSink(sink LoginGrantSink)
AttachLoginGrantSink joins the sink to this Authenticator. Called once at boot, before anything is served; a nil sink detaches, which restores the unwidened login exactly.
func (*Authenticator) CallbackHandler ¶
func (a *Authenticator) CallbackHandler(w http.ResponseWriter, r *http.Request)
func (*Authenticator) ChartRoleMap ¶ added in v0.7.0
func (a *Authenticator) ChartRoleMap() map[string]string
ChartRoleMap returns a COPY of the boot-time WARDYN_OIDC_ROLE_MAP (Config.RoleMap) entries — the console's People page uses it to show which rows are chart-owned (and therefore always win a collision, see mergeRoleMaps) versus console-managed. A copy, not the live map, so a handler can never mutate boot config through the returned value.
func (*Authenticator) DefaultRole ¶ added in v0.7.0
func (a *Authenticator) DefaultRole() string
DefaultRole returns Config.DefaultRole — the role a login falls through to when the merged map (chart or console) is non-empty but nothing in it matched. Empty means "deny", the same as an unset WARDYN_OIDC_DEFAULT_ROLE.
func (*Authenticator) HasEmailDomains ¶ added in v0.7.0
func (a *Authenticator) HasEmailDomains() bool
HasEmailDomains reports whether Config.AllowedEmailDomains (WARDYN_OIDC_EMAIL_DOMAINS) is non-empty — the console's People page EMAIL_KEY badge copy depends on this: without a domains list configured, email_verified is not enforced (see oidc.go's boot/callback warnings), a distinction the response otherwise has no way to express.
func (*Authenticator) HasOperatorEmails ¶ added in v0.7.0
func (a *Authenticator) HasOperatorEmails() bool
HasOperatorEmails reports whether Config.LegacyAdminEmails (WARDYN_OIDC_OPERATOR_EMAILS) is non-empty — the console's People page needs this to explain why a row's value already resolves to admin independent of anything it manages (see mergeRoleMaps' shadow rule).
func (*Authenticator) IsOperatorEmail ¶ added in v0.7.0
func (a *Authenticator) IsOperatorEmail(v string) bool
IsOperatorEmail reports whether v case-insensitively matches an entry on Config.LegacyAdminEmails (WARDYN_OIDC_OPERATOR_EMAILS) — the console's People page write boundary uses this to name the SAME shadow cause mergeRoleMaps' operator-allowlist arm already enforces at login time (collision_operator_allowlist, not a second, possibly-drifting rule): a row a write would add is refused up front with the reason it would be silently shadowed anyway, rather than accepted and only discovered inert the next time someone signs in. Delegates to emailInList, the exact match deriveRole's own allowlist check uses.
func (*Authenticator) Issuer ¶ added in v0.7.0
func (a *Authenticator) Issuer() string
Issuer returns the PUBLIC OIDC issuer URL (Config.IssuerURL) — the console's People page derives a human-facing provider name from it (e.g. an Entra tenant issuer -> "Microsoft Entra ID") so the SSO chip names WHERE sign-in comes from, not just THAT it is SSO.
func (*Authenticator) LoginHandler ¶
func (a *Authenticator) LoginHandler(w http.ResponseWriter, r *http.Request)
LoginHandler initiates the OIDC authorization code flow. It generates a random state and nonce, stores them in HttpOnly SameSite=Lax cookies, and redirects the user to the IdP authorization endpoint.
func (*Authenticator) LogoutHandler ¶
func (a *Authenticator) LogoutHandler(w http.ResponseWriter, r *http.Request)
LogoutHandler clears the Wardyn session cookie and redirects to "/".
func (*Authenticator) MergedMapEmpty ¶ added in v0.7.0
func (a *Authenticator) MergedMapEmpty(rows []RoleMapping) bool
MergedMapEmpty reports whether the ACTUAL merged role map (chart plus the given console rows, applying mergeRoleMaps' own collision/shadow rules) is empty — the console's People page needs this instead of a raw row count (len(chart)==0 && len(rows)==0), which diverges from the real map whenever a stored row is shadowed by the chart or the operator allowlist and therefore contributes nothing to what deriveRole actually looks values up in.
func (*Authenticator) Middleware ¶
func (a *Authenticator) Middleware(next http.Handler) http.Handler
Middleware returns an http.Handler wrapper that:
- If a valid (non-expired, correctly signed) session cookie is present, sets the HumanPrincipal on the request context and calls next.
- Otherwise falls through to next without a principal, allowing the integrator's adminAuth bearer path to handle the request. When a session cookie WAS presented but rejected (tampered/malformed, or valid-but-expired), the rejection reason rides along on the context — see SessionRejectedFromContext — so the integrator's eventual 401 can name it instead of failing silently.
This design lets the integrator compose: oidc.Middleware(adminAuth(handler)).
func (*Authenticator) PreviewRole ¶ added in v0.7.0
func (a *Authenticator) PreviewRole(ctx context.Context, roles, groups []string, email string) (role string, matched []Match, ok bool, err error)
PreviewRole runs the SAME derivation CallbackHandler would for a login carrying roles/groups/email — including a real read of Config.RoleMappings when wired — so the console's People page can show an operator "who would this row make an admin" without waiting for that person to sign in. err is non-nil only when the store read itself failed (couldn't-check, distinct from ok=false's "checked, and nothing matched"); it never falls back to an env-only preview, for the same fail-closed reason CallbackHandler doesn't.
roles/groups/email need no pre-normalization from the caller: deriveRole does its own lowering at lookup time, and PreviewRole reuses it rather than duplicating that rule here.
func (*Authenticator) PreviewRoleAgainst ¶ added in v0.7.0
func (a *Authenticator) PreviewRoleAgainst(rows []RoleMapping, roles, groups []string, email string) (role string, ok bool)
PreviewRoleAgainst is PreviewRole's PURE twin: it runs the identical mergeRoleMaps + deriveRole derivation against a CALLER-SUPPLIED candidate rows slice instead of a real Config.RoleMappings read, so the console's write-boundary lockout guard (POST/DELETE /access/mappings) can ask "would the acting admin still derive admin AFTER this proposed write" by constructing the candidate rows slice itself (the current store list, with the one row added/removed) — without a second store round trip, and without reimplementing merge/derive precedence at the API layer where it could drift from what a REAL login would actually decide.
No store read, no error return: rows is exactly what merged, this is resolution over data already in hand. Compare PreviewRole, which reads Config.RoleMappings itself and can fail on that read (err) — this method never does, because it never reads anything.
func (*Authenticator) SetMemberMode ¶ added in v0.7.4
func (a *Authenticator) SetMemberMode(w http.ResponseWriter, r *http.Request, on, noCredential bool) (stampedRole string, err error)
SetMemberMode flips this request's session into or out of "view as member" mode, writes the re-encoded cookie to w, and returns the session's STAMPED role — what the mode pauses.
The stamped role is returned rather than left for the caller to re-derive because the caller CANNOT: RoleFromContext is already clamped to member while the mode is on, so a caller reading it back would record "member" as the role being paused, and a security_admin would be recorded as an admin. This function has the cookie open anyway; it is one return value, not a second parse of the session.
It decodes the caller's CURRENT cookie rather than taking a Session: the cookie is the only authority on what this human's session says, and building one from context values would silently drop every field the context does not publish (IssuedAt above all). A missing/invalid/expired cookie returns decodeSession's own error — ErrNoSession or ErrInvalidSession — which the caller answers as a 4xx; this function never mints a session, only re-signs the one presented.
Setting the mode it is already in is a no-op in effect and still re-writes the cookie, which costs one Set-Cookie header and keeps the handler free of a branch whose only job would be to answer 200 twice.
ONE exception, and it is not cosmetic (W6-4): a caller whose STAMPED role is already member, asking to turn the mode ON, gets no cookie at all. There is nothing to pause — but the flag does not know that, and everything that reads it keys on the flag rather than on the tier: /me would answer member_mode:true, the console would paint a banner naming an admin role this human does not hold, and BOTH credential-mint doors (which read MemberModeFromContext, not the stamped role) would refuse this member their own SSH key and API token with "Exit member mode…" — breaking the member Getting Started's own "Connect your tools" card until they found the banner's Exit. The route is classMember so that the EXIT is always reachable, which makes this state reachable too. Turning it OFF still re-signs, always: that direction has to work from inside the mode.
Note the deliberate asymmetry with clearCookie's callers: this never CLEARS the session. Toggling off has to leave the human signed in — it is the exit from the mode, and an exit that signed you out would be a trap.
noCredential (0.7.5) selects the SECOND posture, "view as a new member who has not signed in" — see Session.MemberModeNoCredential. It is stored as `on && noCredential` rather than verbatim, which is what makes turning the mode OFF clear it by construction: there is no path that leaves the preview bit set on a session whose mode bit is not, so nothing downstream has to defend against that pair. It rides BELOW the real-member early return above for the same reason the mode bit does — a real member has no credential of their own to hide from themselves, and the doors that key on the preview would refuse them their own sign-in.
type Config ¶
type Config struct {
// IssuerURL is the OIDC provider's PUBLIC issuer — the URL the user's
// browser is redirected to and the value of the "iss" claim in ID tokens
// (e.g. http://localhost:5556). Must match the IdP's configured issuer.
IssuerURL string
// InternalIssuerURL, when set, is the address at which wardynd itself
// reaches the IdP for server-side calls (discovery, token exchange, JWKS)
// — e.g. http://dex:5556 on a Docker network. It solves the split-horizon
// problem where the browser and the control plane reach the IdP at
// different hostnames: the browser-facing endpoints keep the public
// IssuerURL, while wardynd's HTTP client transparently rewrites the public
// authority to this internal one. Empty => IssuerURL is used for both.
InternalIssuerURL string
// ClientID is the OAuth2 client identifier registered with the IdP.
ClientID string
// ClientSecret is the OAuth2 client secret. Never log this value.
// OPTIONAL (C2): empty registers this as a PUBLIC client — PKCE S256 is
// sent on every login regardless (LoginHandler), so a public client is
// not weaker, just a different registration shape some IdPs require
// (a SPA/native-app client type with no secret at all). See New's
// AuthStyleInParams wiring for why omitting the field here is not
// enough on its own.
ClientSecret string
// RedirectURL is the callback URL registered with the IdP.
// Must be <wardynd-base>/auth/callback.
RedirectURL string
// AllowedEmailDomains, when non-empty, restricts login to email addresses
// whose domain — the part after the last '@' — exactly equals one of the
// listed values, case-insensitively. Matching is exact, not suffix-based:
// listing "example.com" does NOT admit "eng.example.com", which must be
// listed separately; wildcards are not supported.
// An empty list checks neither the email domain nor email_verified.
// With a non-empty list, login is denied unless the IdP returns
// email_verified=true and the email's domain matches.
// Entra ID tokens typically OMIT email_verified entirely, so this option
// fail-closes on every login against an Entra tenant; prefer Entra App Roles
// (WARDYN_OIDC_ROLE_MAP against the "roles" claim, below) plus the app
// registration's "assignment required" setting there instead.
AllowedEmailDomains []string
// RoleMap maps a case-insensitive claim/email value — an Entra App Role
// from the ID token's "roles" claim, a "groups" claim entry, or the user's
// email — to a Wardyn role, RoleAdmin or RoleMember. Parsed from
// WARDYN_OIDC_ROLE_MAP by ParseRoleMap ("value=role" CSV pairs, e.g.
// "Wardyn.Admin=admin,eng-team=member,alice@corp.com=admin"); ParseRoleMap
// is also where a bad role value is rejected, so every entry here is
// already valid. Precedence when more than one entry matches: ANY match
// resolving to RoleAdmin wins over one resolving to RoleMember, regardless
// of which claim produced it (see deriveRole). Empty (the default) disables
// role derivation entirely: every signed-in human keeps today's pre-0.5
// behavior (RoleAdmin) — opt-in and upgrade-safe.
RoleMap map[string]string
// DefaultRole is the role a signed-in human gets when RoleMap is non-empty
// but nothing in their roles/groups/email matched an entry: RoleAdmin,
// RoleMember, or "" (the default) to DENY the login instead, with a message
// telling them to ask their operator for a WARDYN_OIDC_ROLE_MAP entry.
// Ignored when RoleMap is empty (see RoleMap's own empty-map behavior).
DefaultRole string
// LegacyAdminEmails is WARDYN_OIDC_OPERATOR_EMAILS — the operator allowlist,
// now the SOLE source of the admin tier (internal/api's isOperator reads the
// derived role, not this list directly). An email on it always derives
// RoleAdmin, same top precedence as any RoleMap admin match, and with no
// RoleMap at all the list alone splits admin from member — so a 0.4.5
// deployment keeps its operators as admins and everyone else as members
// (viewers) with zero re-configuration, with or without a role map.
LegacyAdminEmails []string
// SecureCookies, when true, marks every cookie Wardyn issues (the session
// cookie and the one-time login state/nonce/pkce cookies) with the Secure
// attribute, so browsers only send them over HTTPS. It MUST be true exactly
// when the connection is TLS-protected — either wardynd serves TLS directly
// or TLS terminates at an upstream reverse proxy. CRITICAL: Secure cookies
// are never sent over plain HTTP, so leaving this false (the default) is
// required for plain-HTTP demo deployments — otherwise login silently breaks.
SecureCookies bool
// Revocations is the pg-backed revoke-a-human-now lever (D16). Sessions
// are stateless signed cookies with no server-side session table (see the
// package doc's "Session storage" section), so there is nothing to delete
// on revoke — instead Revocations tracks a per-principal (and a global)
// CUTOFF time, and Middleware treats any session whose IssuedAt is
// at-or-before the applicable cutoff as invalid. nil (the default) means
// revocation is never checked — unset changes nothing, same as every
// other optional Config field.
Revocations SessionRevocations
// RoleMappings is the console-managed (Getting Started → People) role-
// mapping store. When wired, CallbackHandler reads it once per login and
// MERGES its rows with the chart's WARDYN_OIDC_ROLE_MAP (RoleMap, above) —
// see mergeRoleMaps: the chart wins a duplicate key. A wired store's read
// error DENIES the login exactly like the no-match case, via the distinct
// authErrorRoleCheckUnavailable code — it never falls back to the
// env-only map on error, since that could WIDEN access under
// DefaultRole=admin. nil (the default) is env-only role derivation,
// byte-identical to before this field existed — the same
// optional-Config-field rule Revocations, above, follows.
RoleMappings RoleMappingSource
// OnLogin, when set, is called synchronously from CallbackHandler after a
// login is APPROVED (role derived, session about to be issued) with the
// ID token's sub and the freshly-derived role. It exists for exactly one
// caller today — internal/api wires it to refresh ssh_public_keys.role /
// role_checked_at (migration 0046) for every key this principal owns, the
// bounded-stale re-check the SSH gateway's admin override reads — but this
// package stays store-agnostic: it knows nothing about SSH keys, only that
// a login happened. A failure inside OnLogin must never fail the login
// itself (the integrator is expected to log-and-continue, not panic);
// CallbackHandler does not inspect its return because it has none. nil
// (the default) is a plain no-op, so every existing caller is unaffected.
OnLogin func(ctx context.Context, sub, role string)
}
Config holds the OIDC client configuration. All fields except AllowedEmailDomains and ClientSecret (C2: optional, for a public client) are required.
type LoginGrant ¶ added in v0.7.10
type LoginGrant struct {
// RefreshToken is the durable half of the grant. Empty means the provider
// issued none — `offline_access` was not asked for, or not granted.
RefreshToken string
// Scope is the GRANTED scope string, verbatim and unparsed. It is not the
// requested one: a provider may grant more or less than was asked for, and
// only the sink knows which scopes it was hoping for.
Scope string
// Expiry is the access token's expiry, carried as the one freshness signal
// the exchange actually reported.
Expiry time.Time
}
LoginGrant is what the login token exchange returned BESIDES the identity.
It deliberately carries no access token. An access token from the login exchange is minutes old by the time anything downstream wants one and cannot be renewed by whoever holds it; the refresh token is the durable half, and the party that stores it is the only party that may redeem it.
type LoginGrantSink ¶ added in v0.7.10
type LoginGrantSink interface {
// LoginScopes returns the extra scopes this login should request, or nil
// for none. Called once per authorization request.
LoginScopes(ctx context.Context) []string
// CaptureLoginGrant is offered the grant once the login is APPROVED —
// past every denial branch, so a refused login never yields a credential.
// subject is the verified id_token subject, which is the principal the
// session is about to be issued for.
CaptureLoginGrant(ctx context.Context, subject string, grant LoginGrant)
}
LoginGrantSink is the two halves of the seam: what to ADD to the authorization request, and what to DO with the grant that comes back.
Both are best-effort by contract. LoginScopes returning nil (or a sink that was never attached) leaves the authorization request byte-identical to a deployment that never heard of this file. CaptureLoginGrant's outcome is not reported and cannot be: its caller has already decided to sign this person in, and a storage failure must not undo that.
An implementation MUST NOT panic and MUST NOT block for long: it runs inside a browser's login redirect, with a human waiting on it.
type Match ¶ added in v0.7.0
type Match struct {
// Value is the claim/email value that matched, exactly as the ID token
// or email carried it — matching itself is case-insensitive (deriveRole
// lowers before lookup) but Value is not lowered, so a preview can show
// the human the literal claim that hit. Empty for a MatchSourceDefaultRole
// match, which was not driven by any claim value at all.
Value string `json:"value"`
Role string `json:"role"`
Source MatchSource `json:"source"`
}
Match is one claim/email value that contributed to a derived role, with where it came from. deriveRole returns every match it found — not only the one that decided the outcome — so a caller (CallbackHandler's audit log, PreviewRole's console preview) can show WHY a role came out the way it did, not just which role won.
The json tags are POST /access/preview's wire shape: internal/api returns this type directly rather than copying it into a twin that could drift.
type MatchSource ¶ added in v0.7.0
type MatchSource string
MatchSource names which config source produced a Match: a chart WARDYN_OIDC_ROLE_MAP entry or a merged-in console row (MatchSourceMapRow — mergeRoleMaps folds both into one map before deriveRole ever runs, so the two are indistinguishable by the time a Match is built), the WARDYN_OIDC_OPERATOR_EMAILS allowlist (MatchSourceOperatorAllowlist), or DefaultRole's no-match fallthrough (MatchSourceDefaultRole).
const ( MatchSourceMapRow MatchSource = "map_row" MatchSourceOperatorAllowlist MatchSource = "operator_allowlist" MatchSourceDefaultRole MatchSource = "default_role" )
type RoleMapping ¶ added in v0.7.0
RoleMapping is one console-managed (Getting Started → People) value=>role row. Value is EXPECTED already canonical — trimmed, ASCII, lowercase — the API layer that owns writes to this store is responsible for canonicalizing a row before it ever reaches here. mergeRoleMaps no longer trusts that contract blindly, though: a non-canonical row (empty, whitespace, non-ASCII, mixed case) or an invalid Role is DROPPED AND LOGGED (via mergeRoleMaps' shadowed return), never stored verbatim — an empty/ whitespace Value would otherwise match ANY empty/whitespace claim (ASCIIOnly("") is true) and an invalid Role would be a dead key that still changes deriveRole's arm. A canonical, valid row is still stored under Value exactly as given, the same way ParseRoleMap's chart keys are already lowercase by the time deriveRole looks one up.
type RoleMappingSource ¶ added in v0.7.0
type RoleMappingSource interface {
// ListRoleMappings returns every console-managed row. A non-nil error
// DENIES the login in progress (CallbackHandler fails closed rather than
// falling back to env-only, which could WIDEN access under
// WARDYN_OIDC_DEFAULT_ROLE=admin) — this is not a best-effort read.
ListRoleMappings(ctx context.Context) ([]RoleMapping, error)
}
RoleMappingSource is the console's store-backed role-mapping source, read once per login by CallbackHandler and merged with the chart's WARDYN_OIDC_ROLE_MAP (Config.RoleMap) — see mergeRoleMaps. Config.RoleMappings wires it in; nil (the default) disables the console source entirely, the same additive-optional-Config-field rule SessionRevocations follows.
type Session ¶
type Session struct {
// V is the payload format version (SessionCodecVersion), stamped by
// encodeSession so no caller has to remember it. A payload carrying any
// other value — a pre-0.7 cookie's absent key decodes to 0 — is not a
// session.
V int `json:"v"`
Sub string `json:"sub"`
Email string `json:"email"`
// Name is the IdP's display-name claim ("name"), carried for the console
// header ONLY: it gates nothing, keys nothing, and is never logged (the
// same hygiene Email already keeps). omitempty and no codec bump on
// purpose — an absent key decodes to "" and the header falls back to the
// email, which is fail-safe, so a pre-0.7.1 cookie stays a session.
Name string `json:"name,omitempty"`
Role string `json:"role"`
Expiry time.Time `json:"expiry"`
// IssuedAt (D16) is when CallbackHandler minted this cookie — the value
// SessionRevocations.IsSessionRevoked compares against a revoke cutoff.
// omitempty, unlike Groups below: an absent key decodes to the zero
// time either way (a pre-D16 cookie or a same-version cookie that
// happened to omit it are indistinguishable, and both SHOULD read as
// "issued at the beginning of time" — see IsSessionRevoked's doc — so
// there is no second state worth spending cookie bytes to keep apart).
IssuedAt time.Time `json:"iat,omitempty"`
// Groups is the LOGIN-TIME SNAPSHOT of the human's group identity: the
// normalized union of the ID token's "roles" and "groups" claims (see
// sessionGroups). It is what a `group`-subject capability grant matches
// against — folding Entra App Roles in for free, since those arrive on
// "roles" and are the claim an Entra admin can actually assign.
//
// It is a SNAPSHOT and nothing refreshes it: a group added at the IdP
// reaches Wardyn on the human's next login, and that ceiling is published
// rather than hidden (grants themselves resolve per request from the DB, so
// only MEMBERSHIP is stale, never the grant list).
//
// NO omitempty, deliberately. nil and empty must stay distinguishable
// across the cookie round trip: a PRE-0.6 cookie has no groups key at all
// and decodes to nil ("we never asked"), while a 0.6 login with no groups
// encodes `[]` and decodes to an empty non-nil slice ("we asked, there were
// none"). That is the ONLY signal for groups_snapshot_stale — with
// omitempty both cases would encode identically and a member holding a
// pre-upgrade cookie would be told their group grants simply do not apply.
// The cost is 12 bytes of cookie.
//
// decodeSession is deliberately not widened to require this FIELD — but it
// does require the codec VERSION, and that is the fact that decides who is
// signed out. `sess.V != SessionCodecVersion` (session_codec.go) is an exact
// compare, and a pre-0.7 cookie carries no "v" key at all, so it decodes to
// 0 and is refused outright: upgrading to 0.7 signs every SSO human out
// ONCE, on their next request. This comment used to assert the opposite —
// that an older cookie survived the upgrade and nobody was forced to sign in
// again — and three shipped documents were written from it.
//
// So the nil-vs-empty signal above discriminates within ONE codec version:
// a session this binary wrote either has groups or has `[]`. The pre-0.6
// no-groups-key case it was designed for cannot reach the decoder any more —
// keeping the distinction is what lets the NEXT codec-compatible change
// carry a "we never asked" session without inventing a second flag.
Groups []string `json:"groups"`
// GroupsTruncated reports that Groups is a PARTIAL snapshot — sessionGroups
// hit the maxSessionGroupsBytes cap and dropped the alphabetically-last
// entries (PF-26).
//
// It is an AUTHORIZATION INPUT, not telemetry. A member in enough groups
// loses the very group whose governance assignment walls them, and without
// this bit the ceiling resolver would answer "no group matched" and hand
// them the deployment ceiling — a silent widening with no refusal and no
// audit line. internal/api therefore treats a truncated snapshot exactly as
// it treats a missing one.
//
// omitempty is SAFE here only because SessionCodecVersion exists: false and
// "key absent" mean the same thing within one codec version, and a
// pre-0.7 cookie — where they would NOT mean the same thing — never
// decodes at all.
GroupsTruncated bool `json:"groups_truncated,omitempty"`
// MemberMode (0.7.4, P2) is the "view as member" flag: this human asked to
// be treated as a member for the rest of this session, so an admin can see
// what a member sees without a second identity.
//
// It is a CLAMP INPUT and nothing else. Role above stays the
// sign-in-derived truth — never rewritten — and contextWithPrincipal is the
// single place the two meet: the EFFECTIVE role published on the context is
// RoleMember while this is set. That split is what makes toggling OFF safe:
// the stamped role is read back off the cookie rather than re-derived from
// the Groups snapshot above, which is a SNAPSHOT and may be truncated, so a
// re-derivation could answer "member" and strand an admin outside their own
// deployment.
//
// It only ever LOWERS. No code path writes Role, and there is no value of
// this field that widens anybody — which is the property that keeps a
// session-cookie flag from being a privilege primitive.
//
// omitempty and NO codec bump, the same argument Name above makes: an absent
// key decodes to false, which is the ordinary signed-in admin, and false is
// the FAIL-SAFE answer here (unlike GroupsTruncated, where false is the
// fail-open one). The cost is the rolling-upgrade window: during a k8s
// rollout a 0.7.3 replica ignores "mm" and answers as admin. A bump would
// close that window by 401-ing every live cookie mid-rollout, which is
// worse — so docs/OPERATIONS.md publishes the window as a ceiling and
// phrases the mode as "see what a member sees", never as proof that a
// member is refused.
MemberMode bool `json:"mm,omitempty"`
// MemberModeNoCredential (0.7.5, field report finding 3) is the SECOND
// posture of the same mode: "view as a NEW member — one who has not signed
// in yet". Meaningful only with MemberMode; SetMemberMode writes it as
// `on && noCredential`, so turning the mode off clears it by construction
// and it can never be set on its own.
//
// MemberMode alone cannot show that state. It clamps the effective ROLE and
// deliberately leaves Sub — ownership, the secret namespace, every audit row
// — the admin's own, so an admin who has completed their own AWS SSO sign-in
// keeps reading their own live credential while "viewing as member", which
// is the one state every new member on a per_user deployment is NOT in.
// Clamping Sub instead would have rewritten identity, which is the property
// the whole mode rests on not doing.
//
// A SEPARATE BOOL, not an enum on "mm": a 0.7.4 replica decodes "mm" as a
// bool, so a non-bool value there would fail json.Unmarshal in decodeSession
// and 401 a live cookie mid-rollout. An unknown omitempty key is simply
// ignored (the codec sets no DisallowUnknownFields), so no codec bump.
//
// The rolling-upgrade window is the same one MemberMode publishes and it
// fails SAFE in the same direction: an older replica ignores this key and
// shows the admin their own credential — the 0.7.4 behaviour, never a
// widening. docs/OPERATIONS.md publishes it as a ceiling.
MemberModeNoCredential bool `json:"mmnc,omitempty"`
}
Session is the content of the wardyn_session cookie, signed and stored client-side. Sub, email, role, and expiry are persisted. Role is always non-empty in a cookie this package issues — CallbackHandler denies the login rather than write one with an undefined role — and decodeSession treats an empty Role (a pre-0.5 cookie, or a corrupt payload) as no session.
type SessionRevocations ¶ added in v0.6.0
type SessionRevocations interface {
// IsSessionRevoked reports whether a session for this human, issued at
// issuedAt, must be treated as revoked — because of a revoke targeting
// EITHER of their identities, or the reserved "" (global revoke-all) sub,
// whichever cutoff is later.
// issuedAt.IsZero() (a pre-D16 cookie with no iat) is always revoked once
// ANY matching cutoff exists: an old session predating this feature has
// no reliable issued-at to compare, so it fails closed the moment revoke
// is used for the first time against it, rather than staying immune.
//
// BOTH IDENTITIES, for the reason every other user-addressing surface in
// the product already takes: an admin naming a human "shouldn't have to
// guess which the IdP made authoritative" (docs/OPERATIONS.md on a
// subject_type=user capability grant, which matches the sub OR the email;
// capabilitySubjects in internal/api offers both the same way). Revocation
// was the one such surface keyed on sub ALONE, and on any IdP where the two
// differ — Entra, where sub is an opaque per-app identifier — a revoke
// naming the email stamped a cutoff that matched nobody: 204, an audited
// success, and a compromised human still signed in. sub is compared
// EXACTLY (an OIDC sub is opaque and case-sensitive); email is compared
// case-insensitively, since that is how humans type one.
//
// ponytail: Middleware calls this on every authenticated request with no
// in-process cache — one extra lookup per request against the store
// wardynd already requires (Postgres), over a table holding one row per
// revoked principal plus the global one. Add a short-TTL in-memory cache
// keyed on sub if that round trip ever shows up in latency; a POC-scale
// deployment's request volume doesn't justify one yet, and a cache is one
// more place revocation could go stale.
IsSessionRevoked(ctx context.Context, sub, email string, issuedAt time.Time) (bool, error)
// RevokeSub invalidates every CURRENT session for sub, effective now —
// a targeted "log this one person out everywhere". sub is whichever
// identity the caller named; IsSessionRevoked matches it against both.
RevokeSub(ctx context.Context, sub string) error
// RevokeAll invalidates every CURRENT session for every principal,
// effective now — the incident-response "log everyone out" lever.
RevokeAll(ctx context.Context) error
}
SessionRevocations is the store D16's revoke-a-human-now admin action reads and writes. It is scoped to the STATELESS OIDC human session cookie (Session, above) — a distinct concern from internal/identity's per-run SPIFFE-style identity_revocations denylist, which this package never touches.