UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

169 lines (168 loc) • 9.57 kB
/** * hosting/signin/audit — the sign-in door's audit trail: one record per * sign-in outcome, handed to a sink the app owns AND written as one log line. * * const door = signInDoor({ * passwords, store, publicUrl, production, * onAudit: (record) => securityLog.write(record), // typed, optional * // log: (line) => logger.info(line), // default: console.info * }); * * ── Why a door option and not a typed event ───────────────────────────────── * Every typed event carries `EventMeta`, whose `runId` and `runtimeStageId` * are REQUIRED, and is emitted by one agent's dispatcher. A sign-in is never a * run: no agent exists when a person types their password. The library's own * answer for door facts that happen before any run is a SINK at the door — * `standingAgent({ onIngressDecision })` (ingressRecord.ts) — and this is the * same shape, owned by the one place every sign-in outcome is decided. * * ── What a record carries, and what it never does ─────────────────────────── * Classes and identifiers only: the outcome, a reason class, the strategy, the * ACCOUNT the typed name reaches (the checker's `budgetKey` — `alice`, * `CORP\alice` and `alice@corp.example` are all `alice`), the id the strategy * PROVED, the client address after trusted-proxy resolution, the time, and on * `directory-password` the AD sub-code with its name. Never the password, * never a cookie or its key, never a token, never an error's message. * * ── It is not the audit hash chain ────────────────────────────────────────── * Like the ingress record it is a STREAM into your own sink, not hashed, not * sequenced and not verifiable by `verifyAuditBundle`. Chain it in your own * store if you need tamper evidence. */ /** * How a sign-in ended. The first four are attempts at `/auth/login` (and a * redirect callback); the last two are a sign-in ENDING. * * - `'signed-in'` — a sign-in was created (200 / the callback's 303). * - `'refused'` — the credential or the request was refused (401, 400, the * door guard, a redirect callback that failed on the browser's side). * - `'limited'` — an attempt limit refused it before any check (429). The * directory was NOT contacted. * - `'unavailable'` — the door could not answer (503): the directory or the * identity provider unreachable, the checks full, the store down. * - `'signed-out'` — `POST /auth/logout` ended a live sign-in. * - `'expired'` — a sign-in was found past its lifetime or idle limit and * ended. Recorded when it is FOUND (the next request carrying it), not at * the instant it lapsed; a sign-in never presented again is not recorded. * A sign-in's END is filed once, however many requests race on it. */ export type SignInOutcome = 'signed-in' | 'refused' | 'limited' | 'unavailable' | 'signed-out' | 'expired'; /** * Why a password check refused a credential — what a {@link PasswordChecker} * may say through its `note` (types.ts). The person never sees it; it travels * to the operator's record only. * * - `'wrong-credential'` — the backend refused the name and password * (`directory-password`: the bind; the AD sub-code says more). * - `'unknown-account'` — no such name (`local-password`; a directory cannot * tell this apart from a wrong password without a service account). * - `'name-refused'` — the typed name never reached the backend (it fails the * name rule, or names another domain). * - `'malformed-request'` — an empty password or a control character. * - `'account-unresolved'` — the bind worked but Who-am-I did not name an * account of this domain, or not EXACTLY one entry matched it. * - `'not-in-group'` — the person is not in the required group. */ export type PasswordRefusalReason = 'wrong-credential' | 'unknown-account' | 'name-refused' | 'malformed-request' | 'account-unresolved' | 'not-in-group'; /** * The reason class beside an {@link SignInOutcome} — the field a dashboard * groups by. Every value belongs to one outcome: * * - `'signed-in'`: `'accepted'`. * - `'refused'`: every {@link PasswordRefusalReason}; `'cross-site'` (the door * guard refused a login: a foreign Origin, a Host not configured, a form * post); and a redirect callback's `RedirectFailure` codes — * `'state-mismatch'`, `'idp-error'`, `'exchange-failed'`, * `'id-token-refused'`, `'not-a-person'`. * - `'limited'`: `'name-budget'` (the account's attempt budget is spent), * `'name-in-flight'` (a check for the account is still running), * `'address-budget'` (the opt-in hard per-address budget is spent). * - `'unavailable'`: `'backend-unreachable'` (the password never left this * process), `'check-failed'` (it failed after it was sent — counted), * `'checks-busy'` (the door-wide check cap is full), `'limits-full'` (the * attempt counters are full of penalties), `'store-failed'`, * `'idp-unavailable'`. * - `'signed-out'`: `'logout'`. `'expired'`: `'lifetime'`, `'idle'`. */ export type SignInReason = 'accepted' | PasswordRefusalReason | 'cross-site' | 'state-mismatch' | 'idp-error' | 'exchange-failed' | 'id-token-refused' | 'not-a-person' | 'name-budget' | 'name-in-flight' | 'address-budget' | 'backend-unreachable' | 'check-failed' | 'checks-busy' | 'limits-full' | 'store-failed' | 'idp-unavailable' | 'logout' | 'lifetime' | 'idle'; /** * What an Active Directory bind refusal's sub-code (`data 52e` in the LDAP * error) means, as a word. Anything not in the list — `525` (no such user), * `530`/`531` (logon hours, workstation), a code a later AD invents — is * `'unknown'`, and the raw code still travels beside it. * * AD reports most of these only when the password was RIGHT (an expired or * disabled account with a wrong password is `52e`); `775` (locked) comes back * whatever the password. */ export type AdSubCodeName = 'bad-password' | 'locked' | 'password-expired' | 'must-change' | 'disabled' | 'account-expired' | 'unknown'; /** What a password check may tell the door about a refusal — never a secret. */ export interface PasswordCheckDetail { readonly reason: PasswordRefusalReason; /** `directory-password`: AD's bind sub-code, lower-case hex (`'52e'`). */ readonly adSubCode?: string; /** `directory-password`: what that sub-code means. */ readonly adSubCodeName?: AdSubCodeName; } /** * One sign-in outcome, as plain data. Every field is a class of what happened * or an identifier the operator needs; nothing here is a secret and nothing is * a message. */ export interface SignInAuditRecord { /** When the door decided, epoch ms (the door's `now`). */ readonly at: number; readonly outcome: SignInOutcome; readonly reason: SignInReason; /** Which strategy: `directory-password`, `local-password`, `oidc-token`, … */ readonly strategy: string; /** * The ACCOUNT the typed name reaches — the checker's `budgetKey` * (`directory-password`: lower-cased, any `DOMAIN\` and `@domain` removed, * so `user`, `DOMAIN\user` and `user@dns.domain` are all the * sAMAccountName). Absent when no name was read (a redirect sign-in, a body * that did not parse, a request the door guard refused). */ readonly account?: string; /** The id the strategy PROVED — on `signed-in`, `signed-out` and `expired` only. */ readonly userId?: string; /** * The client address, after trusted-proxy resolution (the rightmost * `X-Forwarded-For` hop that is not a trusted proxy; the socket's address * otherwise). Absent on `expired`, which no request of that person caused. */ readonly address?: string; /** `directory-password`: AD's bind sub-code (`'52e'`), when the bind was refused with one. */ readonly adSubCode?: string; /** `directory-password`: what the sub-code means (`'bad-password'`). */ readonly adSubCodeName?: AdSubCodeName; } /** * Where records go — `signInDoor({ onAudit })`. Called synchronously, once per * outcome. A sink that throws is contained (a broken log must not turn a * sign-in into a failure) and reported once through the door's `warn`. Do the * slow part yourself: buffer, and never `await` the network inside it. */ export type SignInAuditSink = (record: SignInAuditRecord) => void; /** The prefix of the door's audit log line; the rest is the record as JSON. */ export declare const SIGN_IN_AUDIT_PREFIX = "[identity] sign-in"; /** * The record as ONE log line: the prefix, then a JSON object with `time` (ISO * 8601, UTC) in place of `at`. JSON so a name holding a quote or a space can * never forge a second field, and so a log pipeline parses it without a * grammar of its own. */ export declare function signInAuditLine(record: SignInAuditRecord): string; /** * The door's bookkeeping: build a record from what it decided, send it to the * sink and the log, contain both. @internal — the option is public, this is not. */ export interface SignInAuditTrail { file(record: Omit<SignInAuditRecord, 'at'>): void; } export declare function signInAuditTrail(options: { readonly sink?: SignInAuditSink; readonly log: (line: string) => void; readonly warn: (message: string) => void; readonly now: () => number; }): SignInAuditTrail;