Skip to content

Decision log

port49 writes one JSON record for every decision it makes, and each record is one line of the log. The log is port49's own record of what it decided, for whom and against which rule. It is separate from the accounting records a device sends, which report what a device did. Every key is snake_case.

Every record has the same envelope: ts, event and, for a decision, verdict and reason. The blocks that follow (auth, device, session and the rest) appear on the records they apply to. Most fields are left out of a record when they have no value, so a query must treat a missing key as "not set".

A record that names its policy blob can be replayed offline and must give the same verdict. The reason codes page lists every value of reason.

The meanings are the policy-engine spec's own text, so a section or decision number in one points into the spec. A lab ID in a meaning is an entry of the spec's lab register, marked open or closed. It stays open until a lab test has measured the behavior it names, and a claim that cites an open entry is not yet measured.

Events

A decision event records one decision, with a verdict and a reason.

Event Emitted by What it decides
mint broker which grants matched, which roles composed, which blob resulted. A record a sweep page writes through the mint (POST /v1/tokens by scope) carries sweep: {scope, after}; the revocation re-check's records after the mint do not (§13 decision 42 ruling 4). A record of a device under a per-login requirement carries per_login_mfa: {accept, reuse_for_seconds, levels, method, proved_at, reused} (§5.1.5)
redeem daemon whether a presented credential is valid, and creates the context
authorize_session daemon the session and its returned attributes (§6)
authorize_command daemon one command vector
escalate daemon an enable exchange, attributed to the principal (§7)
refresh broker whether a live context takes the policy now in force, or is revoked (§5.1.3); or, on a session refresh (/v1/refresh), whether it is extended, with session.auth_time and session.session_expires (§5.6); or, on a policy refresh, session.session_expires where the ceiling moved earlier. A context on a platform that never asks about a command carries session.unreached: true (decision 21). On a session refresh, a context under a requirement no proof covers is deny per_login_mfa_required, not extended (§5.1.5). A refresh record under a requirement carries per_login_mfa: {…} as the mint's does
revoke broker a revocation by principal or by certificate (§5.1.4): what it reached, and the live contexts it could not; a revocation lifted; or, over HTTP, a principal ending its own sessions (by: self) or a (device, username) key ended (by: key), neither leaving a mark
identity broker what GET /v1/identity resolved the caller to: permit grants_matched when its subject holds a grant unexpired now, else deny no_grants_matched (design document §17)
explain broker a hypothetical decision POST /v1/explain answered, about the caller or, under explain_others, another principal: its verdict and reason are the answer given, or deny api_right_missing. It authorizes nothing, and replay passes over it
issue broker whether a person's sign-in is exchanged for a port49-issued certificate: an OIDC sign-in at POST /v1/certificate (§13 decision 43 ruling 8), or a directory-password sign-in at POST /v1/sign-in or /v1/sign-in/confirm (§13 decision 47): permit certificate_issued, carrying certificate: {serial, issuer, fingerprint, not_after} and auth.oidc or auth.directory_password: {amr, auth_time, entry_sha256}, or a deny with the refusal's reason. A directory sign-in's record carries sign_in: {source}, and on a deny failed (name, second_factor, second_factor_skew, password, directory_state, enrollment, principal_limit, source_limit) with skew_seconds, directory_result and directory_data where they apply. It never carries the name typed, the password, the code or the secret. A broker that cannot issue (issuance_unavailable) records nothing
per_login_mfa broker whether a per-login proof posted to POST /v1/mfa is accepted (§5.1.5; §13 decision 45): permit per_login_mfa_proved or deny per_login_mfa_refused, carrying per_login_mfa: {method, challenge_sha256}. A proof the broker cannot judge (store_unavailable) records that
admin_touch broker an admin-level call's proof outcome (§5.1.6; §13 decision 54 ruling 15): deny admin_touch_required (a challenge was issued, nothing done), permit admin_touch_proved (the operation's own record follows), deny admin_touch_refused or deny admin_touch_unavailable, carrying admin: {operation, target, challenge_sha256, why}, where why is on a refusal or an unavailable answer one of a closed set of fixed words and never text from the request: no_roots, bearer, no_client_certificate, no_exporter, malformed_proof, unknown_challenge, touch_slot_chain, session_slot_chain, device_mismatch, touch_policy, session_key, bad_signature. A record never carries the challenge, the signature, the attestations or the exporter value; the cause text is in the API answer alone
enroll broker whether a person's TOTP second factor is enrolled (POST /v1/sign-in/confirm; §13 decision 47 ruling 4): permit second_factor_enrolled, carrying auth.directory_password and sign_in: {source}, or a deny with the sign-in's reasons. Every enrollment is alertable: inside an enrollment window a stolen password enrolls the thief's authenticator
blob broker not a decision: the full compiled blob, emitted once per hash so replay depends on the log alone (§5.1.1)

Report events

A report event shares the envelope but is not a decision: it carries no verdict.

Event Emitted by What it reports
account daemon A device's accounting record: what the device did, after the fact. It carries the record block and no policy block.
account_gap daemon Accounting records port49 received and did not write to the log, with the gap block: the stage, how many and since when. A window with a gap cannot say a device did not ask.
unvalidated daemon A packet no key validated, or a connection the listener refused or ended: the unvalidated block, with a diagnosis, the cleartext header fields and a hash of the body. Never the body.
inventory_change daemon and broker One security-relevant change a policy or inventory swap made, in the change block, with the device in the device block. port49_inventory_changes_total{kind} counts them.
directory_forget broker An operator ran port49 directory forget for a principal, which deleted the principal's directory membership and the entry it was pinned to. The forget block holds the entry.
second_factor_reset broker An operator reset a person's second factor (port49 directory reset-second-factor), or port49-broker init-enrollments created the enrollment store. The second_factor_reset block holds the cleared enrollment.
sign_in_unlock broker An operator cleared a principal's failed-attempt count (port49 directory unlock). The sign_in_unlock block holds the count the broker held.
enrollments_import broker An operator ran port49 directory import, which filled or replaced the enrollment store. The enrollments_import block holds the record count and the export time.
generic_platform daemon port49d serves an admitted device whose platform has no adapter, with the set's generic adapter. One record per such device at start and at each bundle swap. It carries no verdict, and the broker does not write it.

Fields

Field Type Description
ts timestamp When the record was written, as an RFC 3339 timestamp.
event string What the record is about. The two events tables list each value: the decision events and what they decide, then the report events.
verdict string permit or deny. Absent on the report events, which decide nothing, and on an explain that answered with a mint error.
reason string Why the verdict is what it is: one value of the closed reason vocabulary.
principal string Who the decision was about.
auth.method string How the caller authenticated.
auth.credential_sha256 string The SHA-256 of the credential presented on the wire. The credential itself is never logged. A mint record has no value here and carries mint.token_sha256 for each token it issued.
auth.fingerprint string The SHA-256 of a client certificate that the trust anchor verified and the broker then refused. It says whose certificate it was. Identity, not a secret.
auth.directory.source string Where the directory membership this decision used came from: fresh (read from a server for this request) or grace (the stored membership, still inside the grace period).
auth.directory.verified_at string When the membership was last verified against the directory.
auth.directory.server string The directory server the membership was read from.
auth.directory.site string The directory site the membership was read from.
auth.oidc.issuer string The issuer of the verified OIDC token.
auth.oidc.sub string The subject of the verified OIDC token.
auth.oidc.sid string The identity provider's own session id, when the token carries one.
auth.oidc.amr list of string The token's authentication methods references. Recorded because how someone authenticated cannot be established afterwards. No decision reads it.
auth.oidc.acr string The token's authentication context class reference. Recorded and read by nothing.
auth.oidc.auth_time string When the identity provider says the person authenticated.
auth.k8s.cluster string The configured name of the cluster whose verifier accepted the service account token.
auth.k8s.issuer string The issuer of the verified service account token.
auth.k8s.sub string The subject of the verified service account token.
auth.k8s.serviceaccount_uid string The UID of the service account.
auth.k8s.pod string The name of the pod the token was issued to.
auth.k8s.pod_uid string The UID of the pod the token was issued to.
auth.k8s.node string The node the pod ran on, when the token names one.
auth.k8s.jti string The token's id, when it carries one.
auth.directory_password.amr list of string The methods a directory-password sign-in used.
auth.directory_password.auth_time string When the sign-in authenticated.
auth.directory_password.entry_sha256 string A SHA-256 of the directory entry that signed in. Never the password or the code.
device.nas string The device's address as port49 saw it.
device.name string The device's name in the inventory.
device.platform string The device's platform in the inventory.
device.key_ref string The key that validated the exchange. Set on every record of a validated exchange and on no other. A record from the TLS listener has none.
device.key_version integer The version of that key. port49 keys status reads a rotation's phase-out from it.
device.transport string tls on a record from the TACACS+ over TLS (RFC 9887) listener. Absent on the RFC 8907 listener's records.
device.cert_sha256 string On a TLS record, the SHA-256 of the device's verified leaf certificate.
session.ctx_id string The id of the context (the session state port49 holds for a login). It joins the records of one login.
session.username string The username the device sent. Left empty when the device sent a token-shaped username, which the log never carries.
session.username_redacted boolean true when the device sent a token-shaped username and it was left out of session.username.
session.replaced_ctx_id string On a redeem that replaced an existing context for the same device and username, the id of the displaced context. A second login by the same principal joins the context and sets nothing here.
session.replaced_unreadable boolean true when the replaced context could not be opened (sealed under a key that has left the two-key window, or failing authentication), so its id cannot be named.
session.revoked_at string On a redeem refused credential_revoked, when the revocation that refused it was made.
session.privilege_clamped.from integer The privilege level the context held before a login joined it.
session.privilege_clamped.to integer The level it was lowered to, which is the newest login's ceiling. Another window's next command above this level is denied privilege_exceeded.
session.auth_time string On a refresh record that moved it, the session's new authentication time.
session.session_expires string On a refresh record that moved it, the session's new ceiling; on a redeem refused session_expired, the ceiling that refused it.
session.unreached boolean true on a refresh record for a context on a platform that never asks about a command, or on a device whose inventory entry gives no platform. The live session keeps what it had.
session.port string The port (tty) the device reported on this exchange.
session.rem_addr string The remote address the device reported on this exchange.
session.privilege integer On authorize_session and authorize_command, the privilege level in the request's header, which is the level the device asked at. On other events, what that event says: a refresh's is the context's level and a redeem's is the login's start level.
session.held_privilege integer The privilege level port49 held for the context when the request arrived, before any raise this request makes. Present on authorize records whose context was read and is this user's, denials included. Compare it with session.privilege to see what the device asked for against what port49 held.
request.service string The service attribute of the request. Recorded on every authorize_session and authorize_command record, denials included, so it can be grouped by. It is checked against the platform's session services and nothing else.
request.raw list of string The request's arguments as the device sent them, after redaction: a word that matches a secret rule is replaced with «redacted» first, and request.redacted says so.
request.vector list of string The request's command as a list of words.
request.redacted boolean true when words were replaced with «redacted» before the record was written. A secret is never logged.
packet_fields.action string The authentication action, as a name. Authentication requests only.
packet_fields.authen_type string The authentication type, as a name.
packet_fields.service string The authentication service, as a name.
packet_fields.authen_method string The authentication method, as a name. Authorization and accounting requests only.
packet_fields.priv_lvl integer The privilege level in the request header.
reply.attributes list of string The attributes a permitted authorization returned to the device, as sent, with every placeholder filled.
flags string On an account record, the accounting flags the device sent, such as stop.
attribution string On an account record, whether the record was tied to a login port49 knows. resolved: it was. foreign: a record for a session whose start port49 never saw, on a device where it did watch this user's other sessions begin (a local fallback, a console login, or a device-local account with the same name); this is the value to alert on, and port49_accounting_attributions_total{attribution="foreign"} counts it. system: the device acting on its own behalf. unattributed: none of these.
task_id string The task_id of an accounting record.
record.raw list of string On an account record, the accounting arguments as the device sent them, after redaction: a word that matches a secret rule is replaced with «redacted» before the record is written, and record.redacted says so.
record.vector list of string On an account record, the command as a list of words.
record.redacted boolean true when words were replaced before the accounting record was written, so no secret reaches the disk.
record.priv_lvl integer The privilege level in the accounting record.
record.start_time integer The device's start time for the task, when it sent one.
record.stop_time integer The device's stop time for the task, when it sent one.
record.elapsed_time integer The device's elapsed time for the task, when it sent one.
mint.token_sha256 list of string The SHA-256 of each token the mint issued. The token itself never appears in the log.
mint.ttl_seconds integer How long the minted tokens live, in seconds.
mint.blob string The hash of the policy blob the mint resulted in.
policy.bundle string The policy version the blob was compiled from.
policy.blob string The hash of the compiled blob the decision was made against. With policy.bundle, it is what makes the record replayable.
policy.subject string The subject that matched the principal.
policy.subjects list of string Every subject that matched, sorted, when the principal matches more than one. policy.subject is then absent.
policy.grants.[].scope string On a mint record, the scope of a grant that selected the device.
policy.grants.[].roles list of string The roles that grant gives.
policy.grants.[].expires string When the grant stops matching. Empty means never.
policy.matched_deny.rule list of string The rule's terms as written.
policy.matched_deny.specificity list of integer The specificity tuple the rule scored against this command.
policy.matched_deny.role string The role that carries the rule. The rule is the deny that decided a no_permit_dominates_deny, or the most specific deny a permit overrode.
policy.matched_permits.[].rule list of string The rule's terms as written.
policy.matched_permits.[].specificity list of integer The specificity tuple the rule scored against this command.
policy.matched_permits.[].role string The role that carries the rule. The permits that matched the command are listed most specific first, and the first decided a permit.
policy.stage integer The 1-based pipeline stage that a pipe_stage_denied refused. The stage's words are in request.raw.
blob.hash string The hash that names the blob.
blob.body object On a blob record, the compiled blob whole, emitted once per hash, so replay depends on the log alone.
blob.redacted boolean true when rule text in the body was redacted before it was logged. The body is then not the blob the hash names, and replay reports every record that names it unverifiable.
unvalidated.source string The source address of a packet no key validated.
unvalidated.diagnosis string Why the packet or connection is on this record. no_known_key_matches: no key of the device's key_ref validates it. matches_retired: a retired key validates it, and unvalidated.version says which. unencrypted_refused: the packet was not obfuscated. not_tacacs: the header is not TACACS+. unadmitted_source: the source address is not in the inventory. malformed_after_validation: the key validated and the body did not decode. device_quarantined: the device is admitted but quarantined by policy. admission_refused: the device is in the inventory and admission refused it. admission_withdrawn: a reload renamed, re-keyed or re-platformed the device of an open connection. transport_mismatch: the device reached the listener it does not speak. tls_refused: the TLS listener refused or ended the connection, and unvalidated.tls_outcome says why. obfuscated_over_tls: a packet over TLS with the unencrypted flag clear.
unvalidated.key_ref string The key tried, when there was one.
unvalidated.version integer The version of the key (the numbered version within the device's key_ref) that the packet matched, or that the connection speaks. During a key rotation, a matches_retired record carries the retired version a device is still using, which is how you find the devices that have not moved to the new key. Absent where no key matched.
unvalidated.type integer The packet type in the header.
unvalidated.seq_no integer The sequence number in the header.
unvalidated.session_id integer The session id in the header.
unvalidated.length integer The body length in the header.
unvalidated.body_sha256 string The SHA-256 of the body. The body itself is never logged.
unvalidated.transport string tls on a record from the TLS listener.
unvalidated.tls_outcome string On a record with diagnosis: tls_refused, why the handshake was refused. See the values below.
unvalidated.detail string A bounded, printable note on a record from the TLS listener, or on a transport_mismatch record from either listener (which names the transport the device record says and the listener it reached). It is never a body.
unvalidated.cert_sha256 string On a record with diagnosis: tls_refused, the SHA-256 of the certificate the device sent, where it sent one.
server_msg string The message the reply carried to the person at the device. A denial's names the rule that decided and does not echo the command.
gap.stage string Where accounting records were dropped: offer_refused (answered ERROR, so the device may send the record again), drain_unparseable or drain_set_aside (answered SUCCESS and not in the log).
gap.records integer How many accounting records were dropped at this stage.
gap.since string The earliest arrival among the dropped records, as far as the spool knows it.
change.kind string What changed. Only security-relevant changes are written: device_added (a device added and admitted), admission_changed (a device already in the inventory becoming admitted), address_added (an address added to a device already in the inventory), key_ref_changed, transport_changed and platform_changed (a device's key assignment, transport or platform changed), and scope_joined or scope_left (a device entering or leaving a scope with security_log: true).
change.scope string The scope involved, when the change names one.
change.address string The address involved, when the change names one.
change.from string The earlier value, on key_ref_changed, transport_changed, platform_changed and admission_changed.
change.to string The later value, on those kinds and on device_added, where it is admitted.
change.from_bundle string The policy version before the swap that made the change.
change.to_bundle string The policy version after it.
forget.entry_id string The id of the directory entry the principal was pinned to.
forget.dn string The distinguished name of that entry.
forget.verified_at string When the entry was last verified or refused.
forget.refused boolean true when the last answer for the entry was a refusal.
forget.origin string Who asked for the principal to be forgotten.
sweep.scope string On a mint record made through a sweep page, the scope the sweep walked.
sweep.after string The page's cursor.
revocation.by string What the revocation was made by, such as a principal or a certificate.
revocation.issuer string The issuer of a certificate revoked by serial.
revocation.serial string The serial of a certificate revoked by serial.
revocation.oidc_issuer string The OIDC issuer of a session or subject ended by a back-channel logout or a revocation by sid or sub.
revocation.sid string The OIDC session id that was ended.
revocation.sub string The OIDC subject that was ended.
revocation.principals list of string Every principal a logout reached, when its owner record named more than one. The record's principal is then the first.
revocation.device string The device a revocation was limited to.
revocation.username string The username a revocation was limited to.
revocation.at string When the revocation was made.
revocation.origin string Who made the revocation.
revocation.contexts integer How many live contexts it reached. Zero is left out.
revocation.token_keys integer How many token keys it reached. Zero is left out.
revocation.connections integer How many connections it ended. Zero is left out.
revocation.unreached.[].device string A live context the revocation could not reach: its device.
revocation.unreached.[].username string A live context the revocation could not reach: its username.
revocation.saved boolean Whether broker.revocations_file was written. Present for a standing mark only: absent for a self or key revocation, which writes none, and on a broker that keeps no revocations_file.
revocation.error string What went wrong, when something did.
explain.asked_by string The principal that asked the hypothetical question.
explain.asked_by_fingerprint string The fingerprint of the certificate the asker presented.
explain.command boolean true when the question was about a command. The command itself is never recorded.
explain.mint_error string The mint error the broker would have answered instead of a token. The record then has no verdict.
certificate.serial string The serial of the certificate issued, as port49 revoke --cert-serial takes it.
certificate.issuer string The distinguished name of the CA that issued it.
certificate.fingerprint string Its SHA-256 fingerprint.
certificate.not_after string When it expires.
per_login_mfa.accept list of string On a mint or refresh record under a per-login requirement, the proof methods the requirement accepts.
per_login_mfa.reuse_for_seconds integer How long a proof is reused for, in seconds.
per_login_mfa.levels list of string The privilege levels the requirement covers.
per_login_mfa.method string The method of the proof that covered the record, or of the proof a per_login_mfa record judged.
per_login_mfa.proved_at string When the covering proof was made.
per_login_mfa.reused boolean true when an earlier proof was reused.
per_login_mfa.challenge_sha256 string On a per_login_mfa record, the SHA-256 of the challenge. The challenge itself is never logged.
admin.operation string On an admin_touch record, the operation the proof was for.
admin.target string The operation's target, as the challenge shows it.
admin.challenge_sha256 string The SHA-256 of the challenge. The challenge, the signature and the attestation are never logged.
admin.why string For a refusal or an unavailable answer, a fixed cause.
sign_in.source string The source network a directory-password sign-in came from.
sign_in.failed string On a refusal, which check failed.
sign_in.skew_seconds integer For a clock too far off, by how many seconds.
sign_in.directory_result integer The directory's result code, on a refusal that has one.
sign_in.directory_data string The directory's diagnostic data, on a refusal that has one. Never the name typed, the password or the code.
second_factor_reset.enrolled_at string When the enrollment that was cleared was made.
second_factor_reset.enrolled_from string Where it was made from.
second_factor_reset.reopen_until string Until when enrollment is open again. Absent on a store creation.
second_factor_reset.all boolean true for a reset of every enrollment.
second_factor_reset.created boolean true when the record is the creation of the enrollment store.
second_factor_reset.origin string Who made the reset.
sign_in_unlock.failures integer The failed-attempt count the answering broker held. Absent when its session store did not answer the clear: the unlock is then in force but the count is unknown, which is not zero.
sign_in_unlock.origin string Who cleared the count.
enrollments_import.records integer How many enrollment records the import wrote.
enrollments_import.exported_at string When the imported export was made.
enrollments_import.replaced boolean true when the import replaced an existing store.
enrollments_import.origin string Who ran the import.
index_error string What a failed session-index write cost, on the record for the session that lost it. That session is missing from the index, so a later revocation cannot find it. This is different from revocation.unreached, which lists live contexts a revocation found and could not reach.

The tls_outcome values

A record from the RFC 9887 (TACACS+ over TLS) listener carries transport: "tls". An unvalidated record with diagnosis: tls_refused carries the tls_outcome that says why the handshake was refused. The handshake counter port49_tls_handshakes_total uses the same values as its outcome label. Strike says whether the refusal counts against the source address, as a wrong key does.

tls_outcome Means Strike
version_refused The ClientHello offered no TLS 1.3. yes
no_client_certificate The device sent no certificate. yes
unknown_ca Its chain reaches no client_ca_file anchor. yes
certificate_expired A certificate in its chain is expired or not yet valid. yes
certificate_invalid Any other path error, a serverAuth-only leaf included. yes
certificate_revoked A certificate in its chain is revoked. yes
status_unavailable Its status could not be established under on_unavailable: refuse. A decline: the window stands until it lapses. no
identity_mismatch The leaf does not name the device its source address is. yes
sni_mismatch The ClientHello asked for a server name not in server_names. A decline: the window stands until it lapses. no
early_data_refused The ClientHello carried early_data (RFC 9887 section 5.1.2). yes
handshake_timeout No handshake within handshake_timeout. yes
handshake_failed Anything else: a bad signature, a client abort. yes

Two more values are never in a record. They are label values of port49_tls_handshakes_total only, and both mean the device was admitted:

  • ok: The handshake completed and the device's certificate was admitted. Counted after the status check passes.
  • resumed: The handshake resumed an earlier TLS session and the device was admitted.

packet_fields

A record for a validated request carries packet_fields: the request's header fields as names, not numbers. These are action (authentication only), authen_type, service, authen_method (authorization and accounting only) and priv_lvl. A value outside the protocol's list is written unknown_<n>, where <n> is the header byte. A record for an unvalidated packet has no packet_fields; it has the unvalidated block, which holds only cleartext header fields.

What the log never holds

The log never contains a token, a password, a challenge or a response. A credential appears only as credential_sha256. The body of an unvalidated packet is never logged, only its hash. A token typed as a username leaves session.username empty and sets session.username_redacted to true.