Certificate lifetime policy
cert_options.* is the outer bound on what any certificate may carry.
Lifetime policy is the layer inside it that shapes each individual issuance
from what the approver’s identity satisfies and the network the request came
from. It is evaluated during approval and applies to all four certificate
types.
For PAM and console the gate is the axis that matters in practice: duration
tiers against a 30-second, validated-once certificate and extension grants
against an empty ceiling have nothing to do, so the expected configuration for
those types is require alone.
The rule
Section titled “The rule”Policy may only ever narrow. Nothing reachable over HTTP can make issuance more permissive than the config file already allows:
- shorter lifetime, never longer;
- fewer extensions than the type’s configured set, which is the outer bound;
- more critical options, on the service path only.
A tier may grant extensions, which reads like widening and is not: the grant
is bounded by the type’s extensions ceiling, and a grant naming anything
outside that ceiling fails startup rather than being silently trimmed.
Identity grants, network narrows.
Adding a critical option reads like widening and is the opposite: it can only prevent uses that would otherwise be allowed.
The final duration is always clamped to the type’s valid_duration ceiling,
and again by the global
max_cert_lifetime
(or
max_service_cert_lifetime)
defense-in-depth check before signing.
Configuration
Section titled “Configuration”authentication: fields: extra: loc: level_of_confidence # the claim is named once, by the operator
cert_options: user: valid_duration: 10h # the ceiling; policy only reduces from here extensions: # the ceiling for grants - permit-pty - permit-agent-forwarding
require: # who may approve at all all_of: - group: "SSH Users" - claim: loc at_least: 20
lifetime_policy: default_duration: 15m # required whenever a policy is configured default_extensions: [] # grants start from nothing when tiers exist tiers: # FIRST MATCH WINS; order is yours to get right - name: cleared # names the row in the recorded explanation when: { claim: loc, at_least: 40 } max_duration: 10h grant_extensions: [permit-pty, permit-agent-forwarding] - name: engineers when: { group: engineers } max_duration: 8h grant_extensions: [permit-pty] - name: contractors when: { group: contractors } max_duration: 1h # no grant_extensions, so grants nothing source_policy: - cidr: 10.0.0.0/8 # office and VPN max_duration: 10h - cidr: 192.168.0.0/16 # lab max_duration: 4h removed_extensions: [permit-agent-forwarding] - cidr: 0.0.0.0/0 # everywhere else max_duration: 15mrequire and lifetime_policy are available under all four of
cert_options.user,
cert_options.service,
cert_options.pam and
cert_options.console.
Service certificates additionally tier the enrollment code’s own lifetime with
lifetime_policy.default_enrollment_duration
and a per-tier max_enrollment_duration, both clamped by
cert_options.service.enrollment_duration.
| Key | What it does |
|---|---|
lifetime_policy.default_duration |
the duration when no tier matches. Required whenever any part of a policy is configured |
lifetime_policy.default_extensions |
the grant when no tier matches, or when the winning tier states none |
lifetime_policy.on_absent_claim |
states the posture for a missing claim. Accepts only floor |
lifetime_policy.tiers |
name, when, max_duration, grant_extensions, and (service only) max_enrollment_duration |
lifetime_policy.source_policy |
cidr, max_duration, removed_extensions, pin_source_address |
Several mistakes are startup errors rather than runtime surprises: an
unparseable CIDR, a configured lifetime_policy with no default_duration, a
tier with no when, a grant_extensions entry outside the type’s ceiling, a
max_enrollment_duration on a type other than service, and the two retired
keys – cert_options.*.require_group and a source rule’s extensions (now
removed_extensions).
Without a default_duration, an identity matching no tier would receive a
zero-second certificate that fails later at signing, several layers from the
line that caused it.
Conditions
Section titled “Conditions”A tier’s when and a type’s require take the same closed grammar:
| Form | Meaning |
|---|---|
group: <name> |
membership in an OIDC group |
claim: <name> with at_least / at_most |
numeric, inclusive bounds |
claim: <name> with exactly |
numeric equality, desugaring to at_least and at_most of the same value |
claim: <name> with equals / one_of |
scalar equality, or against a set |
claim: <name> with contains |
membership in a list-valued claim |
all_of: [...] / any_of: [...] |
one level of nesting, no deeper |
Exactly one of group, claim, all_of or any_of must be set, and a claim
condition takes exactly one comparator family.
Claims are compared as numbers, not strings. That is the whole reason for a
typed accessor: compared as text, "9" sorts above "40", which would hand
the longest lifetimes to the lowest scores.
Every claim a condition names must be declared under
authentication.fields.extra,
checked at startup, so a typo fails the process instead of quietly failing the
condition on every evaluation.
Total denial is the identity provider’s job. An identity that should not reach
ssoosshd at all is expected never to be issued a token; conditions here
shape what an already-admitted identity receives.
How a duration is chosen
Section titled “How a duration is chosen”flowchart TD
accTitle: How a certificate’s duration is chosen
accDescr {
Starting from an approval, the first policy tier whose when clause
matches supplies its max_duration; with no tier matching,
default_duration is used instead. That figure then meets the source
rules: the longest-prefix matching rule reduces it to the smaller of the
two, and no match leaves it alone. The result is clamped to
valid_duration, and the explanation of how it was reached is recorded on
the decision row.
}
R["Approval"] --> T{"First tier whose<br/>when matches?"}
T -- "yes" --> TM["tier.max_duration"]
T -- "no match" --> DD["default_duration"]
TM --> S{"Longest-prefix<br/>source rule?"}
DD --> S
S -- "match" --> SM["min with rule.max_duration"]
S -- "no match" --> NR["no reduction"]
SM --> C["Clamp to valid_duration"]
NR --> C
C --> E["Record the explanation<br/>on the decision row"]
- Tier: the first tier whose
whencondition the approver’s identity satisfies. Order matters here, and it is not validated: numeric thresholds are nested by construction, since everyone satisfyingat_least: 40also satisfiesat_least: 30. Write them in descending order. Ascending order silently lands every high-score identity in the shortest tier – no error, no warning, and the certificate looks normal. The blast radius is bounded by the ceiling, and the natural mistake under-grants, which someone complains about rather than nobody noticing. No match meansdefault_duration. - Source rule: the longest-prefix match against the request’s source address, as in a routing table. Order does not matter. Ties resolve to the stricter rule, so a duplicate can never loosen anything.
- The result is the minimum of those, clamped to
valid_duration.
The winning tier, the condition it matched, the source rule, the ceilings and
the effective values are recorded as a structured JSON document on the
approval’s decision row (certificate_request_decisions.policy_explanation),
so “why did this certificate get fifteen minutes” is answerable from the
record rather than by re-deriving the computation.
Extensions
Section titled “Extensions”Extensions follow their own algebra, in this order:
granted = tier.grant_extensions ?? default_extensions (starts empty)extensions = requested & type.extensions & granted - source_rule.removed_extensionsThe grant axis is active only when tiers are configured. Without tiers, the
type’s extensions ceiling alone bounds a request, exactly as before.
No source match means no reduction
Section titled “No source match means no reduction”The type ceiling applies. Rules are reductions, so the absence of one is
“nothing to reduce”. For a default floor, write explicit 0.0.0.0/0 and
::/0 rules, the same idiom as a firewall’s default rule. The opposite
reading – unmatched networks get the strictest tier – is a reasonable guess
and is not what happens.
IPv4 and IPv6 rules never match each other. Addresses are normalized, so an
IPv4-mapped IPv6 form (::ffff:10.0.0.1) matches a 10.0.0.0/8 rule instead
of silently missing every rule.
Which address
Section titled “Which address”The address recorded on the request (certificate_requests.source_ip, from
the client IP at create time) – where the certificate will be used. Not the
approver’s browser address, which is a different machine on a different
network.
The client-supplied source_addresses in a request is unverified and never
feeds policy.
X-Forwarded-For is ignored unless an operator explicitly trusts a proxy
range.
Source-address pinning
Section titled “Source-address pinning”pin_source_address: true on a source rule pins the issued certificate to the
address it was requested from, so a stolen one is useless elsewhere.
Service certificates only. People move between office, VPN, hotel, and phone tether; pinning a user certificate turns every network change into a failed login, for no security gain that a shorter lifetime does not already provide. Services sit still.
force-command is deliberately outside source policy entirely: “which
command” is not a property of a network.
Service certificates and retrieval
Section titled “Service certificates and retrieval”A service certificate is signed at retrieve, potentially long after approval
and possibly from a different address. Policy is evaluated at enrollment
time and frozen, never re-derived at retrieve time. An unattended job cannot
silently drop into a stricter tier by being moved, because no human would be
present to see why issuance changed.
Worked examples
Section titled “Worked examples”Against the configuration at the top of this page.
A cleared engineer in the office. loc: 55, in engineers, requesting
permit-pty and permit-agent-forwarding from 10.1.2.3.
- Tier:
clearedmatches first (at_least: 40), so 10h and a grant of both extensions. - Source:
10.0.0.0/8is the longest prefix that matches, capping at 10h. - Duration:
min(10h, 10h)clamped tovalid_duration10h, so 10h. - Extensions: requested and in the ceiling and granted, nothing removed, so both.
The same person on hotel wifi. 203.0.113.9.
- Tier: still
cleared, 10h. - Source: only
0.0.0.0/0matches, capping at 15m. - Duration:
min(10h, 15m)= 15m. Same identity, shorter certificate. - Extensions: both – the
0.0.0.0/0rule removes nothing.
A contractor in the lab. In contractors, loc absent, from
192.168.4.10, requesting permit-pty and permit-agent-forwarding.
- Tier:
cleared’s condition fails, because an absent claim fails rather than being skipped.engineersdoes not match.contractorsdoes, so 1h and no grant. - Source:
192.168.0.0/16, 4h, removingpermit-agent-forwarding. - Duration:
min(1h, 4h)= 1h. - Extensions: the tier granted nothing, so the intersection is empty – the removal never even applies.
Someone in SSH Users with loc: 25, from an address matching no rule.
requirepasses (all_ofis satisfied: in the group, and 25 is at least 20).- Tier: no tier matches, so
default_duration15m, anddefault_extensions: []grants nothing. - Source: no match, so no reduction.
- Duration: 15m. Extensions: none.
Nobody in SSH Users. require fails, so there is no approval to shape.
Certificate types are gated before any of the above runs.