Repository navigation
Conversation
- AppHost provides Azure Database for PostgreSQL Flexible Server when published and a postgres:18 container locally; the API uses Npgsql with Aspire's Azure enrichment (managed identity when published). - Replace the SQL Server migrations with a fresh InitialCreate. - Remove Tenant.Code from the schema, contracts, dashboard and agent configuration; agents register with a tenant ID. - Never apply migrations at startup; README documents the dotnet ef database update command for local development. - Tests share one Testcontainers PostgreSQL container; each factory gets its own database cloned from a migrated template. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Test suites
Total: 943 tests — 943 passed, 0 failed, 0 skipped. Github Test Reporter by CTRF 💚 🔄 This comment has been updated |
Tenants can now be created beneath any tenant, up to a configurable maximum depth (TenantTree:MaxDepth, default 20). Creating deeper is refused with a 409 problem. A closure table (ancestor, descendant, depth, with each tenant paired with itself) is derived from the parent pointers. TenantTree is its only writer: each add, move or remove runs in one transaction under pg_advisory_xact_lock and updates the parent pointer and closure rows together. Moves reject cycles and subtrees that would exceed the depth cap; they get an endpoint with tenant transfers. The migration backfills closure rows for existing tenants. Tests cover the depth cap and its configuration, cycle rejection, that tree changes wait for the lock, and a closure table rebuilt from the parent pointers matching the maintained one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A root tenant has depth 0 and a tenant's depth is its closure-table distance from its root. TenantTree:MaxDepth (default 20) bounds that depth, so a tenant at depth 20 is allowed and depth 21 is refused with 409, both on create and on move. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A device is the managed machine and an agent is one installation on it
(ADR-0005). Telemetry, command history and maintenance mode now belong to
the device, so replacing the agent keeps them. A device points at its one
active agent, and its status (Online/Offline) comes from that agent's
check-ins. Devices, commands and telemetry move to canonical /api/v1/
URLs under /devices/{deviceId}; agents send heartbeats and telemetry at
/agents/{agentId}/..., and a replaced agent's check-ins get 409. Device
edits are a JSON merge patch, command cancellation is a :cancel action,
and deleting a device is a device operation. The dashboard lists devices.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…104) Signal9 now runs its own OAuth authorization server (ADR-0002) on a new auth host (ADR-0003). OpenIddict 7.7.2 runs on auth and api against one database, one issuer and one set of keys, each enabling only its own endpoints: - auth serves discovery, which advertises api's RFC 8705 mTLS aliases for the token, revocation and introspection endpoints. - api serves those endpoints but no discovery or key set, and validates access tokens locally against the shared database, with token and authorization entry validation on. - Only client credentials is allowed for now; password, device code and implicit are refused. - Access tokens last 10 minutes, are for the signal9-api audience and carry identity only: subject, client and scopes (ADR-0007). - Quartz on auth prunes expired tokens and authorizations hourly. The database and the shared token rules move to a new OSI.Signal9.Server library, so both hosts use the same DbContext and migrations. api now requires an access token on every endpoint. The test fixture boots auth and api together against the shared PostgreSQL container, and its clients call api with a token minted at auth for a seeded client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The agent hub is now a doorbell (ADR-0009): queuing a command rings
WorkAvailable(), with no content, on the device's active agent, and the
hub has no methods agents can call. Agents list their pending work at
GET /api/v1/agents/{agentId}/pending-commands, and each command carries
its canonical URL, where the agent reports its result. The heartbeat
response says whether work is waiting.
The agent fetches when the doorbell rings, every time the hub
(re)connects, and when a heartbeat says work is waiting, with no
separate polling loop. Session logic moves from AgentWorker into
AgentSession, which an end-to-end fixture test runs against the
in-memory API, covering doorbell then fetch, fetch on reconnect after
missed doorbells, and fetch after a heartbeat.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d by the key-encryption key (#106) Signal9 now generates its own authorization server keys and stores them in the database (ADR-0008, stories 124-127): - Signing (RS256) and encryption keys are RSA-3072, stored as self-signed X.509 certificates whose NotBefore is the activation date. auth and api load the same keys, so tokens issued by auth validate on api. The first host to find no active key generates one, under an advisory lock. - Each private key is sealed under one key-encryption key: a fresh AES-256-GCM key per secret, wrapped by the KEK and bound to its row. - The KEK is chosen by the KeyEncryption section: a Key Vault key reached only by managed identity, or an RSA-3072+ certificate. The AppHost creates a local certificate for development. - The Data Protection key ring is stored in the database and wrapped by the same KEK, so a cookie protected by one instance is readable by a fresh one sharing the database. The development-only OpenIddict certificates and the test fixture's injected keys are gone; tests use a test KEK certificate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TenantTree:MaxDepth may now be 0, meaning root tenants only (a flat deployment). Creating a child tenant or moving a tenant under another is refused with the existing 409 "Tenant tree too deep". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
auth and api share the operational hardening #63 requires, so later tickets attach to it: - Rate limiting with ASP.NET Core's limiter. Policies are configured under RateLimiting:<Policy> and apply no limit until PermitLimit is set: Global (every request, per client IP), Token (the token endpoint and its mTLS alias, per client_id and IP) and Api (api's resources and hubs, per caller). Token requests are limited before authentication, so failed client authentication counts; per-caller endpoints after it. Rejections are 429 ProblemDetails with Retry-After. - Errors are ProblemDetails on both hosts, with no exception details outside Development. - Preload-ready HSTS (two years, includeSubDomains, preload) outside Development. - Liveness and readiness probes are served only on the internal port in HealthProbes:Port, in every environment, matched on the connection's local port. The AppHost adds an internal probes endpoint per host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed (#106) A host whose key-encryption key can't decrypt any key in a non-empty ring no longer adds a key of its own to the shared ring. It disables key generation, so protecting fails on that host instead. An empty ring, or a database that can't be read yet, keeps Data Protection's default. The custom KEK envelope stays on the ring. The built-in ProtectKeysWithCertificate wraps with RSA PKCS#1 v1.5 and uses AES-CBC without a MAC. ProtectKeysWithAzureKeyVault uses AES-128-CBC without a MAC and an unpinned key identifier. Both fall below the standards floor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt (#107) Signing and encryption keys now rotate with no manual work (ADR-0008, story 126): - Each key is active for 90 days from its certificate's NotBefore. The next key is generated 14 days before the active one retires, with its NotBefore at that key's NotAfter, so it is in the key set early and OpenIddict switches to it at its NotBefore. - Retired keys stay loaded, validating and decrypting the tokens they covered, for 90 days (the longest refresh-token lifetime), then are deleted under the key-generation lock. - Every running auth and api instance reloads its keys without a restart: StoredKeys fires an options change token at the next publication, activation, retirement or removal, through a TimeProvider timer. api's validation options, which copy the local server's keys, are rebuilt after the server options. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Disposed factories left their pooled connections open on the shared
PostgreSQL container, so a long run reached its 100-connection limit
("53300: too many clients"). Dropping the database WITH (FORCE) closes
them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agents, API applications that authenticate with a certificate and web get access tokens bound to their client certificate (RFC 8705, ADR-0004). - api's token, revocation and introspection aliases accept self_signed_tls_client_auth and tls_client_auth. tls_client_auth matches the client's one registered RFC 8705 subject (DN or a SAN) against a certificate from a configured authority, instead of OpenIddict's redirect-URI host match. - Access tokens issued over mTLS carry cnf.x5t#S256, and api refuses them with a different certificate or none. Refresh tokens are bound for public clients; confidential clients' are bound by client authentication. - On Container Apps, the certificate comes from X-Forwarded-Client-Cert through ASP.NET Core certificate forwarding with a converter for Envoy's format; the built-in converter can't read it (shown test-first). The header is believed only when forwarding is on and the peer is a trusted proxy, and is removed otherwise. - Self-hosted, Kestrel on api asks for a certificate in the handshake; auth never does. - X-Forwarded-For and X-Forwarded-Proto are trusted only from configured networks, on both hosts, ahead of rate limiting (story 129). - Which proofs are accepted and bound lives in one seam, ProofOfPossession, so DPoP can be added later. - The test fixture drops each test's database when it's disposed: EF Core's Npgsql data sources kept an idle connection to every finished test's database, and the run ran out of PostgreSQL connections. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every single resource and every successful write now carries a strong ETag derived from the row version (PostgreSQL xmin) and the representation's derived values. Writes and lifecycle actions honour If-Match (412 ProblemDetails when stale), GETs honour If-None-Match (304), and a write that races past the check is caught by the EF concurrency token (412 with a precondition, 409 without) instead of silently overwriting. Agent check-ins stay last-write-wins. [OptimisticConcurrency] marks participating endpoints: an architecture test requires it on every write and single-resource GET, it documents ETag, If-Match, If-None-Match, 304 and 412 in OpenAPI, and it fails requests whose endpoint skips the ETag or the precondition check. Tenant endpoints move to /api/v1/tenants. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…110) Every api endpoint now declares the scope it needs, and enforcement is real (#63, ADR-0010): - The scope catalogue (OSI.Signal9.Server/Identity/Scopes.cs) holds every scope in #63's table, {noun}.{verb} with the five verbs, plus the OpenID Connect scopes and agent. OpenIddict issues only catalogue scopes and auth's discovery lists them. - [RequireScope] on each action and hub, built on IAuthorizationRequirementData. Agent-sent endpoints and the agent hub require agent; the dashboard hub accepts devices.read or telemetry.read. - RunScript commands also need terminal.execute, separate from commands.execute (story 73). - A missing token gets 401 and a missing scope 403, both as ProblemDetails; the 403 carries RFC 6750's insufficient_scope challenge naming the scope. - An architecture test fails when an endpoint has neither a catalogue scope nor [AllowAnonymous] (story 62). - The OpenAPI document, now public on api, has an oauth2 scheme with the catalogue and lists each operation's scopes and 401/403 (story 65). Test clients get tokens holding the scopes they need. The dashboard and agent stop working until the BFF and enrollment tickets bring them back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rename OSI.Signal9.API.Tests to OSI.Signal9.IntegrationTests (git mv, namespace, InternalsVisibleTo, solution, docs) and add OSI.Signal9.Agent.Tests on MSTest with CommandExecutor unit tests. The end-to-end agent tests need the API host and stay in the integration suite. AGENTS.md's Testing section now describes the three suites and where a new test goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TenantScope, an EF Core named query filter on every tenant-owned entity, keeps rows whose owning tenant descends from one of the caller's assigned tenants; none sees nothing. Bypasses go only through IgnoreTenantScope with a named TenantScopeBypass reason, and tests enforce both rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tenants are updated with PATCH (application/merge-patch+json, RFC 7396)
instead of a full-replacement PUT, and status is read-only: it changes
through POST /api/v1/tenants/{tenantId}:suspend, :activate and :cancel,
which return 409 when the current status doesn't allow the transition.
Merge patches now refuse members that aren't editable fields. The
dashboard, .http file and README use the new shape.
The agent's CommandExecutor takes TimeProvider and runs commands through
ICommandHandlers, so a unit test proves a command times out with
FakeTimeProvider.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
People sign in at auth through the operator's multi-tenant Entra app registration with Microsoft.Identity.Web: code flow with PKCE upstream, redeemed by MSAL with a certificate locally or a managed identity's federated credential on Azure, never a secret. A sign-in is refused unless amr contains mfa, and an optional allow-list restricts the directories. People are identified by (tid, oid); signing in creates no user record. auth gains the authorization endpoint: confidential clients complete authorization code with S256 PKCE, codes last 5 minutes and work once, and reuse revokes everything issued from them. The endpoint is rate limited by IP through the Authorize policy. The fixture gains a fake Entra provider issuing sign-ins with chosen tid, oid, amr, sid and roles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A person holding the Superadministrator app role on the operator's app registration is Superadministrator only when their directory (tid) is the deploying directory, set by the required Entra:DeployingTenantId; the same role from any other directory is ignored. auth adds a role claim to the access token, the one authorization claim tokens carry (ADR-0006). The role is re-read from Entra for every token: a sign-in session at auth is fresh only for the first authorization request after Entra, and a session that says Superadministrator but already issued a token goes back to Entra, so removing the role takes effect at the next token issuance. In api a Superadministrator's TenantScope covers every tenant tree, only a Superadministrator creates root tenants (403 otherwise), and a deployment.* scope counts only for a Superadministrator. The README documents assigning the app role by hand until first-run setup (#77). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
web is now an ASP.NET Core host that serves a fresh Fluent UI Blazor v5 WebAssembly dashboard and acts as its BFF (ADR-0003). People sign in through web -> auth -> Entra; web redeems its code at api's mTLS token alias with its own certificate, so its tokens are certificate-bound, and keeps them in an encrypted token store in the database. The browser only holds a __Host-Http- session cookie. - web's client certificate is a stored key (WebClient use), wrapped by the key-encryption key and rotated; web registers itself as signal9-web. - YARP forwards a fixed allow-list (/api, /hubs/dashboard, /docs) to api. - State-changing requests need the X-CSRF header; strict CSP on web; no CORS anywhere. - Sessions work across web replicas sharing the database. - Client certificate chains are validated on the injected TimeProvider. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Access becomes tenant-scoped RBAC in Azure RBAC's shape (ADR-0010). Users
belong to one owning tenant and link to an Entra (tid, oid); the built-in
Viewer, Technician and Administrator roles are seeded as data and refuse
modification; Superadministrators and Administrators create role
assignments at /api/v1/tenants/{tenantId}/role-assignments.
Each request reads the caller's role assignments live and feeds the tenant
filter only the tenants where a role grants the endpoint's scope and the
token carries it, so effective access is token scopes ∩ role scopes ∩
subtree, assignments union, and a person with none sees nothing. The
dashboard works again through the BFF for a person with an assignment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refresh tokens are reference tokens that rotate on every use with a 30-second reuse leeway; reuse after it revokes the chain. web asks for offline_access, and its refresh tokens last 12 hours from sign-in however often they rotate. web refreshes an access token a minute before it expires, serialized per session across replicas by a row lock, and signs the person out only on invalid_grant: a transient failure answers 503 and keeps the session. A 401 from api gets one refresh and one retry, replaying the request body. Sessions end after an hour idle (tracked server-side to the minute) and 12 hours in all, at web and at auth; ending a session, including by a background sweep, revokes its refresh token at auth. auth's own session is capped at 12 hours from the Entra sign-in, so reaching the cap sends the person back to Entra. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
web serves Scalar at /docs to anyone, over the OpenAPI document api generates, which web forwards from /docs/openapi/v1.json without a token. The page runs under web's strict CSP with a per-request nonce and loads nothing from other origins. "Try it" calls web's own /api with the CSRF header, so it acts as the signed-in person and no further; without a session web answers 401. The document's server is relative, and requirements implementing IDocumentedRequirement are listed on their operations (x-signal9-requirements and the description), ready for #120 and #122. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security-relevant events are recorded in an append-only audit log, each entry stored at the tenant where it happened (or deployment-level) and readable with audit.read at that tenant or above at /api/v1/audit-entries. Deployment-level entries are visible only to Superadministrators, and the database refuses to update, delete or truncate entries. Retrofitted events: sign-ins and refused sign-ins at auth, repeated authentication failures, authorization denials, tenant creation, moves and deletion, Superadministrator changes, role-assignment creation, and command creation, cancellation and results. Entries refer to tenants, devices, users and roles by ID; names are resolved when read through the reader's tenant filter, and an entry whose device can't be resolved returns IDs only. Each tenant-filter bypass now declares whether its uses are audited. The dashboard gains an audit log page on Fluent UI Blazor v5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[RequireRecentMfa] requires a person who performed MFA within the last 15 minutes, judged from the access token's auth_time and amr. A delegated token without it gets 401 with WWW-Authenticate: Bearer error="insufficient_user_authentication", max_age=900; an application acting as itself gets 403 until MFA-approved grants exist. It is documented in the OpenAPI document and applied to tenant deletion. auth requires Entra's auth_time, copies auth_time and amr into tokens, and on an authorization request whose max_age the session exceeds sends the person to Entra with prompt=login, refusing a sign-in that comes back still too old. web passes the challenge to the dashboard without a refresh-and-retry, the dashboard sends the person through bff/login with maxAge, and the new sign-in replaces the old session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…116) Administrators grant and revoke role assignments within their subtree (DELETE /api/v1/tenants/{tenantId}/role-assignments/{id}), without escalating privileges or locking each other out: - Granting a role beyond the caller's own access at the tenant (their roles' scopes there and their token's scopes) is refused with 403; outside the subtree reads as missing. - Revoking a peer's Administrator assignment needs an Administrator above the tenant (403); removing the last Administrator at a tenant or above it, including yourself, is refused with 409, except by a Superadministrator. The check runs under the tenant-tree lock. - Assignments are immutable: changing a role is revoke and grant. - Revocation takes effect on the holder's next request, and grants and revocations are audited at the tenant. [RequireAncestorAdministrator] marks endpoints only an Administrator above the target tenant may use, and appears in the OpenAPI document. The dashboard gains a role assignments page on Fluent UI Blazor v5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ured (#63) With KnownProxies and KnownIPNetworks both empty, ASP.NET Core's ForwardedHeadersMiddleware believes every peer, so the shipped default let any caller forge X-Forwarded-For (rate limits, audit addresses) and X-Forwarded-Proto (HTTPS checks). With no trusted network, forwarded headers are now ignored entirely, as ForwardedHeadersSettings promised. Tests cover the unconfigured and untrusted-peer cases for X-Forwarded-For/Proto and X-Forwarded-Client-Cert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /devices/{id}/commands checked only terminal.execute and tenant
access before queuing RunScript, so a delegated token with an old MFA
could run code (ADR-0010). RunScript now gets the same rule as
[RequireRecentMfa]: a person needs MFA within 15 minutes, else 401 with
RFC 9470's insufficient_user_authentication challenge; an application
acting as itself needs an MFA-approved grant of terminal.execute and
non-secret authentication, else 403. The rule is shared through
RecentMfa.RequireAsync, and [RequiresRecentMfaWhen] documents it in the
OpenAPI requirements and responses like other MFA-gated operations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deleting a role assignment projected its user's Entra identity through the tenant-filtered Users table, so an Administrator got 404 for a visible assignment in their subtree whose user is owned by another tenant (as an invitation can make). The assignment is now found and authorized through its own tenant filter, and only its holder's identity is read through the new, narrowly scoped TenantScopeBypass.RoleAssignmentHolder ([NotAudited]: it reads one visible assignment's holder, never returned; the revocation is audited). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Enrollment found the token by the presented secret's hash before its transaction, then consumed a use by token ID alone. A rotation committed in between still let the retired secret enroll an agent. The secret hash is now part of the atomic consumption predicate, so the enrollment is refused (400) without taking a use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… descendants (#63) Through a delegated application a Superadministrator is held to the application's subtree with all its scopes (decisions-125), but the synthetic assignment had no role, so [RequireAncestorAdministrator] actions such as deleting a child tenant were refused (403) where an Administrator through the same application succeeds. It's now an Administrator assignment at the application's tenant: ancestor-only actions work on its descendants and, as for every caller through an application, still not on the application's own tenant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Transfer representations named the transfer and both tenants but not
the transfer's own URL, though tenants list incoming and descendant
transfers under themselves (AGENTS.md: responses always use canonical
URLs). Each TenantTransferDto now has Url,
/api/v1/tenants/{tenantId}/transfers/{id}, and the dashboard follows it
for :accept, :decline and :cancel instead of rebuilding it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CommandExecutorTests built the real SystemInfoProvider and TelemetryCollector and asserted Environment.MachineName, so the unit suite read the host machine (AGENTS.md: OS and machine interactions go behind fakes). CollectSystemInfoHandler now depends on IMachineFactsSource and ITelemetrySource, which the real collectors implement and the agent registers; the unit test uses fakes, and the real collectors stay covered by the end-to-end agent tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… policy (#63) The rule that two devices are one machine (same SMBIOS UUID, or same manufacturer and serial) was written out in enrollment, device transfer and tenant transfer; it now lives on HardwareIdentifiers (Matches, and MatchingDevices() for queries), and each caller keeps its own query scope and bypass. TenantTree.RootOfAsync replaces two private copies. Both BFF proxies strip the same request and response headers through ProxyHeaders; each states its Authorization handling explicitly (api gets only the person's token, never the browser's; Azure SignalR Service gets its own client token). Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Device deletion cascaded the agents' rows but left each agent's OpenIddict application, with its tokens, and its hub connection open (decisions-128 handoff; #129 and #134 didn't cover this endpoint). Each agent's client is now deleted with its authorizations and tokens, as tenant deletion does, and its hub connection closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Concurrent enrollments of the same hardware with different tokens could both find no matching device and each create one, so the second skipped the identification decision (#131). Each enrollment now takes the tenant tree lock shared (tenant and device transfers hold it exclusively, so neither can change the tree or move a device in meanwhile), then one transaction-level advisory lock per reported hardware identifier, keyed by the root tenant tree, in a fixed order, inside the existing execution-strategy transaction. The later enrollment sees the first one's device and waits for a decision. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hourly Entra check grouped sessions by MSAL home account only, refreshed
against the first session's directory, and applied that one answer, including
the Superadministrator flag, to every session in the group. A guest's home
account isn't its sign-in directory, so one person's home and guest sessions
shared a verdict: a disabled guest account could stay alive, and a
deploying-directory Superadministrator check could promote a session from
another directory (ADR-0006, story 58).
Sessions are now grouped by home account and sign-in directory (tid), each
group is refreshed against its own directory, and Entra's answer counts only
for sessions whose subject ({tid}:{oid}) matches the refreshed ID token.
Sessions Entra answered someone else for end (audited "Entra answered for
someone else"). Superadministrator still comes only from the deploying
directory's ID token.
Also fixes the refresh call itself: the positional arguments bound the scheme
name to the overload's tenantId and the directory to its userFlow, so the
refresh never went to the session's directory. The fake Entra now models guest
accounts under one home account and refuses refreshes at a directory the person
has no account in, which is how the bug hid.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ending a dashboard session (sign-out, idle, the 12-hour cap, signing in again) no longer deletes its only refresh-token reference before revoking it. The session is marked ended, so nothing can use it at once, and the row stays holding the revocation work until auth answers 200 (RFC 7009 §2.2). Failed revocations are retried by web's sweeper with a lease and a doubling wait (1 to 30 minutes), and given up only once nothing behind the session could still be used (12 hours plus an access token's lifetime from sign-in). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…revoked (#63) A redeem landing between OpenIddict reading a refresh token for revocation and revoking it made OpenIddict's own revocation fail on the changed row (ID2079, unsupported_token_type), and the handler that revoked the whole grant ran after it, so never. The grant revocation now runs before OpenIddict's, revoking the authorization and every token under it (including what the racing redeem issued), and re-reads the token so OpenIddict's revocation succeeds and answers 200 (RFC 7009). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nant (#63) An app-only API application holding the Administrator role is a peer of the Administrators at its tenant (#63 "Restrictions on Administrators at their own assigned tenant", story 22). Removing it, changing its role or taking scopes away now needs an Administrator above the tenant or a Superadministrator (403 "Peer Administrator"); the application itself may still remove itself, as a person may revoke their own assignment. Renaming, widening within the role and credential management are unchanged. Removing an application with its own token no longer fails with a concurrency error: the request's tracked token row is detached before OpenIddict deletes the client's tokens in bulk. Administrator applications don't count as a tenant's remaining Administrator for the lockout guard (characterised by a test). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API applications page registers delegated applications too (#125): the register dialog asks whom the application acts as, and for a delegated one takes its redirect URIs (one per line), its replay protection (PKCE, PKCE and nonce, or nonce only, so PKCE can't be turned off without nonce, story 86) and any OpenID Connect or role scope, with no role. Redirect URIs are checked as the API does (at least one, at most ten, absolute HTTPS without a fragment or user information) before anything is sent. The list shows whom each application acts as, and a delegated application's details show its redirect URIs and replay protection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The fixture's browsers treated any Set-Cookie containing "1970" as a
deletion, and random base64url cookie values (the chunked auth
session cookie) sometimes contain it (12 of 5,328 headers over six
runs). A dropped session cookie sent the browser back to Entra, so
the test saw Entra's state instead of its client's ("state
mismatch"). One shared CookieJar now parses the header and deletes
only on an empty value, Max-Age <= 0 or an epoch Expires.
- SessionCheckTests slept 300 ms for the Entra check to finish after a
transient failure. The outbound resilience handler retries network
errors and 5xx after a backoff on the fixture's FakeTimeProvider, and
Microsoft.Identity.Web retries invalid_client, so the run could still
be in flight. The test now waits for the run's own outcome (the
session handed back as due), letting fixture time pass meanwhile;
the "at most once an hour" check runs the checker directly instead
of sleeping.
- The hourly check recorded the Entra session as ended before ending
the sessions, so a refresh in between revoked the session with
"Entra session ended" instead of Entra's refusal. It now ends the
sessions first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h-OS claims (#63) Identification now looks for matching devices in the enrollment token's whole root tenant tree, not just its subtree. A rebound agent belongs to its device's tenant. Needs-decision candidates are those the caller can see. An agent reporting an operating system installed within the last 24 hours whose hardware matches exactly one device joins it as a held claim. It is always awaiting approval and changes nothing on the device. Approving it at the device's tenant revokes the agent it replaces and applies its facts; revoking it leaves the device alone. The agent reports the install time from the Windows registry, or the Linux root filesystem's birth time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MWG-Logan
marked this pull request as ready for review
October 26, 2026 14:55
…#63) Agent and api share one version (Directory.Build.props) and one set of protocol constants (AgentProtocol in Contracts: routes, token endpoint, scope, version header, refusal). The agent sends Signal9-Agent-Version on every request, including the hub; api checks it against a configurable supported range at enrollment, the token endpoint, the agent's endpoints and the hub, refusing with 400 (a problem+json type, or the OAuth error unsupported_agent_version), and records the accepted version. The agent stops on that refusal instead of retrying, and tolerates enum values newer than itself, so an unknown command type is reported as not supported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…acOS (#63) Everything the agent reads from its OS (hardware identifiers, OS install time, processor, CPU and memory) and its default state directory now come from an IPlatform chosen in Program.cs: Windows (unchanged behaviour), Linux (/proc, sysfs, statx; state in /var/lib/signal9-agent) and macOS (readers not supported yet; state in /Library/Application Support). The agent also runs as a systemd service or launchd daemon. Enrollment reports the OS family (stored on the device and shown on DeviceDto) and cuts the OS description and firmware strings to the contract's limits. Enrollment and the session take the machine-facts and telemetry sources, heartbeats get their address through them, and the delivery tests and agent unit tests use fixed fake machines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…am (#63) CngAgentKeyStore's choice of provider (the TPM where it can hold the key, else the software provider; open and delete across both) now runs over IMachineKeyProviders, with CNG in CngMachineKeys, so the fallback is unit tested with a fake provider on any OS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
) - Request telemetry records invitation links' paths redacted, so a trace never holds a redeemable reference. - Entra sign-in freshness lives in a server-side record the authorization endpoint deletes when it issues or refuses a code, so replaying an older sign-in cookie can't skip a Superadministrator's Entra re-read. - prompt=none answers login_required instead of redirecting; prompt=login and max_age re-authenticate at Entra and accept only the sign-in made for that request, so max_age=0 no longer refuses it. - api reads access tokens only from the Authorization header. - Every host sends nosniff and Referrer-Policy: no-referrer; /api and /bff responses, and every secret-bearing one, are no-store. - Removing an API application credential revokes the application's tokens. - Anonymous enrollment refusals no longer name the tenant or its limit. - One self-signed client certificate policy for agents and API applications, which now really verifies the self-signature. - A test for each certificate refusal branch, asserting its reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Drop the RestartMachine and RestartService command types, which no agent can run, with the dashboard's restart button; #82 designs them. RunScript stays as the carrier of terminal.execute and its step-up until #82. - Remove CommandPriority, SubscriptionPlan and Tenant.Plan, Device.MaintenanceMode and ApiApplicationContracts.IsMutualTls, which nothing reads, with migration RemoveScaffoldFields. - Test AuditLog.IgnoreTenantScopeAsync with an undeclared bypass value, which counts as audited. - Bring OSI.Signal9.API.http, the README's PATCH rows and stale comments up to date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…penAPI flows (#63) - A create's If-Match/If-None-Match are evaluated against the collection, its request target (RFC 9110 §13.1): If-None-Match: * is 412, If-Match: * passes. - web's 401s carry a WWW-Authenticate challenge (RFC 9110 §15.5.2): Cookie realm="signal9", login="/bff/login". - Resending the same command result is 200 with the command as it is, even with a stale If-Match (RFC 9110 §9.2.2, §13.1.1); a different result or a cancelled command stays 409. - OpenAPI describes the authorization-code flow (with refresh) and client credentials, each with only the scopes it can grant, and agent operations under their own scheme at api's mTLS token endpoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nvitation redemption (#63) The anonymous, database-backed endpoints are limited out of the box (OWASP API Security Top 10 API4:2023, ASVS V11.1.4): Token 300 a minute per client_id and IP, Enrollment 60 and InvitationRedemption 30 a minute per IP. Configuration overrides each default, and an empty PermitLimit turns it off; every other policy stays off until configured. The test fixture turns the defaults off unless a test asks for them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hosts' Development settings no longer carry localhost Identity addresses: the AppHost (or the test fixture) provides them, and a host without them refuses to start, naming the setting. The AppHost sets Realtime:Hosting on api and web in both modes, and neither starts without it instead of silently self-hosting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, Razor auth pages, smaller controllers (#63) - LockedTransaction: every explicit transaction runs inside the execution strategy, under an optional advisory lock, from an empty change tracker on every attempt, and leaves nothing tracked when it doesn't commit. - PeriodicJob: one BackgroundService base on the injected TimeProvider, used by the dashboard session sweeper, the Entra session check and the hub access sweeper. - One hub connection registry, options section (Hubs:AccessCheckInterval) and access sweeper for the agent and dashboard hubs. - AdministratorCoverage: one "another Administrator remains" query for role assignment revocation and tenant deletion. - Auth's consent, invitation and front-channel logout pages are Razor components rendered with static SSR, with one set of page security headers. - IEntraRefresh removed (single implementation, no test seam). - AgentEnrollment holds enrollment and identification, out of AgentsController, which maps its outcomes to responses; ApiApplicationCredentials reads credentials, out of ApiApplicationsController. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#63) The shared fixture keeps production IAssignedTenants: the fake Superadministrator default and FixtureAssignedTenants are gone. Callers are explicit: a real Superadministrator signed in at the fake Entra, API applications registered through the API, people with role assignments made through the API, and anonymous clients. Tests assert observable outcomes instead of internals: key rotation through ID token and JWE key IDs, JWKS and refresh-token redemption; the OpenAPI document over HTTP; the Entra session check through what Entra is asked and public responses; replayed session cookies and logged give-ups instead of DashboardSessions counts; tenant subtrees as API applications see them. Wall-clock polling and real-time expiry give way to signals: a span per background job run, started in PeriodicJob (BackgroundJobs), observed requests at the fake Entra, web and the agent transport, a Quartz job listener, hub round trips and PostgreSQL lock waits. Treat any failure asking Entra, other than stopping, as transient, so the resilience pipeline's timeout no longer aborts the whole session check run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing has shipped, so the 26 migrations collapse into one generated from the current model. The audit log's append-only trigger (#115) is carried over by hand; the old backfills and clean-ups only served databases that predate this branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#63) CodeQL (cs/cleartext-storage-of-sensitive-information): the session check logged a person's Entra home account ID when Entra couldn't be asked. Logs now name sessions by Signal9's authorization IDs. CodeQL (cs/user-controlled-bypass) on command creation pointed at a real gap next to it: enums were read from numbers too, so a client could queue a type no member has, and a request with no type defaulted to the first one. Enums are now names only, the type is required, and a type the api doesn't list needs the strictest checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Spec #63 end to end: Signal9 gets its own authorization server, tenant-scoped RBAC over a tenant tree of any depth, and certificate-bound agent identity. Built as 43 ticket commits, a 17-commit code-review fix pass, a 10-commit core-principles fix pass a migration squash and a CodeQL fix: 72 commits, linear on
dev.flowchart LR Browser -->|"__Host- cookie, CSRF header"| web["web (BFF, Fluent UI v5 WASM)"] web -->|"mTLS-bound tokens via YARP"| api web -->|"OIDC + PKCE"| auth["auth (OpenIddict server)"] auth -->|"OIDC, amr=mfa"| Entra Agent -->|"enrollment token, then self_signed_tls_client_auth"| auth Agent -->|"mTLS REST + doorbell hub"| api["api (resource server)"] api --> pg[("PostgreSQL 18")] auth --> pgEvery request is filtered by the caller's live tenant scope (closure table); a filter bypass must declare
[Audited]or[NotAudited(reason)]. Tokens carry identity only, and access is read per request (ADR-0007).Evidence
dev@ e7cd902: the API is anonymous, there's no tenant tree or RBAC, agents authenticate with nothing, the dashboard has no sign-in.After:
integration/63-auth-tenancy@ 1347d04: Release build clean with warnings as errors,dotnet format --verify-no-changesclean, and 943/943 tests pass (IntegrationTests on PostgreSQL via Testcontainers, Web.Tests in bUnit, Agent.Tests).Merge Danger
Door: one-way
The schema is replaced (one new
InitialCreateon PostgreSQL), the old UI and anonymous API are removed, and agents enroll with a new protocol. Pre-1.0 and steamrolled by decision, so there's no data or client to migrate, but nothing built ondev's old shapes survives.Blast Radius: everything
Every endpoint now needs a token and tenant scope; agents and API clients written against
devstop working; operators must configure Entra (deploying directory,auth_timeoptional claim, a Conditional Access MFA policy, front-channel logout URL), trusted proxy networks and real-time hosting. Live paths not exercised by tests: Azure SignalR Service (no emulator for default mode) and real Entra (the deployed proofs are still open).Requires human maintainer review
The review items of #100, #101, #102, #103, #104, #106, #109, #137 and #143 were accepted during the build and aren't repeated here. The one exception is #106's Data Protection key-encryption decision (item 1). 🔒 marks items that affect security.
Already decided by the maintainer (recorded here, no answer needed)
MaxDepth20 allows depth ≤ 20, andMaxDepthmay be 0 (roots only).HeaderConverterreads Container Apps'X-Forwarded-Client-Cert(Certonly; a header with more than one element is refused). The built-in converter can't parse that format.InitialCreate.Hosting and operations
IXmlEncryptorextension point (AES-256-GCM data key per key, RSA-OAEP-256 wrap, Key Vaultkidpinned), and fail closed (no auto-generated key) when no stored key decrypts on this host. Alternatives: the built-inProtectKeysWithCertificate/ProtectKeysWithAzureKeyVault. Why: the built-ins use PKCS#1 v1.5 or OAEP-SHA-1, and AES-CBC with no MAC (AES-128 for Key Vault), with nokidpinning, which is below the standards floor.LISTEN/NOTIFY. Why: the bound holds for any configured token lifetime, and a deterministic schedule adds no moving parts./aliveand/healthare served only on a separateHealthProbes:Port, matched by local port and placed ahead of HSTS, rate limiting and authentication. The AppHost assigns the port. Alternative: probes on the public ports. Why: probes stay off the public surface and outside the limiters.max-age=63072000; includeSubDomains; preloadon every host except in Development. Alternative: a shorter max-age, or no preload. Why: these are hstspreload.org's requirements, and Microsoft advises against HSTS in development.tls_client_authare a server-wide PEM list (Identity:TlsClientCertificateAuthorities); with none set, the method is off. Alternatives:RequireCertificate; a PEM file path or Key Vault. Why: callers without certificates keep working, and OpenIddict accepts trusted roots only globally.ProofOfPossession) configures the accepted proofs and token binding, and enforcement is OpenIddict's built-inValidateProofOfPossession. Alternative: wrap OpenIddict's handler in custom code. Why: no custom PoP code (ADR-0004), and DPoP can be added in the same place later.Entra:DeployingTenantId. Alternative: make it optional and fail closed, so nobody is a Superadministrator. Why: a misconfiguration fails loudly at startup instead of silently.auth_timeoptional claim are refused, and the README tells operators to add a Conditional Access policy that requires MFA at every sign-in. Alternative: fall back to the time auth received the sign-in. Why: Entra SSO can return an old MFA, so the time of receipt would overstate freshness.Dashboard host (web)
MapFallbackToFile; Auto or Server render modes. Why: this is Microsoft's BFF shape, and the import map comes from endpoint metadata, so its CSP hash is exact.self_signed_tls_client_auth, so its tokens are certificate-bound and it has no secret. Its certificate is aStoredKey(new useWebClient, RSA-3072, rotated every 90 days). web registers its own OpenIddict client at startup. Alternatives: a client secret; a separate certificate table; seeding the client from the migration service.__Host-cookie holds only an encrypted ticket withsuband a session ID. Tokens live in aDashboardSessionstable, protected per session, so any replica can serve the session and deleting the row defeats a replayed cookie. Alternative: anITicketStorethat holds the whole ticket. Why: same effect with fewer moving parts.X-CSRF: 1, otherwise 403 problem+json. Blazor's antiforgery is off. Alternatives: 400 (ASP.NET antiforgery's choice) or 401 (Duende's). Why: the request is understood but refused (RFC 9110 §15.5.4).style-src-elem 'self','unsafe-inline'only onstyle-src-attr,img-src 'self' data:). Fluent UI'sFluentLayoutinline<style>is blocked, andapp.cssrepeats its mobile rules. Alternatives:'unsafe-inline'onstyle-src-elem, or droppingFluentLayout. Why: keeps the standards floor./api/**and/hubs/dashboard/**(with the person's token) and the docs document (anonymous). It strips cookies,Authorization, CSRF and forwarding headers. There's no CORS on any host. Authorization responses useresponse_mode=query, and PAR is off for web. Alternative: keep CORS for direct dashboard-to-api calls. Why: the browser never holds a token.TimeProvider(a cloned OpenIddict chain policy). Alternative: the real clock. Why: makes rotation testable. Production behaviour is unchanged.Data and tenancy
EXISTSover the closure table. Device-owned rows (agents, commands, telemetry) are scoped through their device, so a device transfer rewrites nothing. Alternatives: a mapped table-valued function; copying the tenant onto every device-owned row. Why: plain LINQ, proven on PostgreSQL, and history follows transfers for free.TenantScopeBypassenum, used only throughIgnoreTenantScope(reason)and tagged into the SQL. An IL-scanning architecture test fails on any otherIgnoreQueryFilters. Hosts default to no assigned tenants. Alternatives: a Roslyn analyzer; a source-text scan; unfiltered until RBAC. Why: fails closed and keeps every bypass enumerable.POST /tenantsis 400, as if it were missing (Device transfer between tenants #123 does the same for a device transfer's destination). Alternative: 404, as the Superadministrator from the Entra app role #113 handoff suggested. Why:/tenantsexists, and a different answer would reveal that the tenant exists.GET/PATCH/DELETE /rolesneed the scope from a role at some tenant. PATCH and DELETE exist and answer 409 for built-in roles. Role ETags hash the role's content. Alternative: no write methods until custom roles. Why: the refusal for built-in roles is observable, and the API shape is ready for custom roles.Userper Entra (tid,oid) globally, 409 on a duplicate. This reveals that the identity is a user somewhere. Assigning a role needs the user to be visible to the caller. Alternative: allow a user per tenant. Why: one person, one user record. Users owned elsewhere join through invitations.Restrict. Alternative: block or soft-delete. Why: matches the spec's tenant deletion.subis{tid}:{oid}and differs fromclient_id(PersonSubject, shared by auth and api). Alternative: a dedicated claim. Why: works with the existing token contents (ADR-0007).IAssignedTenants.IsSuperadministratormakesTenantScopekeep every row, and this isn't a bypass. Only a Superadministrator creates root tenants (403 otherwise). Alternatives: return every root as "assigned"; a named bypass. Why: a Superadministrator's normal view is every tree, not an exception.deployment.*. Chosen:deployment.*scopes count only for a Superadministrator, enforced inRequireScopeHandler. A token that has the scope but no Superadministrator gets a plain 403 withoutinsufficient_scope. Alternative: rely on role scopes alone. Why: asking for the scope again can't help (RFC 6750 §3.1).roleIdwith the same guards. Why: KISS, and Azure RBAC's shape (ADR-0010).[RequireAncestorAdministrator]semantics. Chosen: a tenant outside the caller's reach passes the requirement, so the tenant filter answers 404 instead of a 403 that reveals the tenant exists. The requirement fails closed when the route names no tenant, and its refusal is a plain 403. Alternative: 403 everywhere. Why: avoids revealing that a tenant exists.TenantTree.LockKeyinside EF's execution strategy (retries on). Enrollment takes the lock shared, plus one advisory lock per (root tree, hardware identifier). Alternatives: per-root locks; disabling EF retries. Why: simplest way to stop races with tree moves. Revisit if it becomes a bottleneck.displayNameis editable, and the Entra identity and owning tenant are read-only (400). Editing a user who holds role assignments needsroleassignments.writeat every tenant where they hold one, out-of-reach tenants included. Alternative: allow re-linking the Entra identity. Why: re-linking would hand that person the user's assignments, which is account takeover.holdsNewAgentsForApprovalis a tenant field (PATCH,tenants.write). The DTO shows the effective value plusapprovalHoldInherited. Turning it off where an ancestor holds is 409, and sending back the inherited value is a no-op. Alternatives: a sub-resource or:hold/:releaseactions; accepting the change silently. Why: it's a setting, not a lifecycle state./tenants/{id}/transfers/{transferId}with:accept,:declineand:cancel, and a tenant view filtered bydirection,statusandincludeDescendants. States are Pending, Accepted, Declined and Cancelled, with no separate Completed state. Alternatives: a top-level/tenant-transfers; separate incoming and outgoing collections; a Completed state. Why: transfers nest under their permanent owner, and accept-and-complete is atomic.:canceladded. Chosen: the proposing side can withdraw a pending transfer, though the ticket doesn't list it. Alternative: no withdrawal. Why: one pending transfer per tenant (partial unique index) would otherwise block the tenant until the receiver declines.[RequireAncestorAdministrator]but no recent MFA. Device transfers need onlydevices.transfer, which Technicians hold, with neither recent MFA nor an ancestor Administrator. Alternative:[RequireRecentMfa]on propose/accept and on device transfer, as one-way actions. Why: the spec reserves recent MFA for tenant deletion, scripts and terminal.MaxDevices, is 409. Alternative: 200 no-op for the same tenant. Why: a transition the state doesn't allow is 409, and a no-op would write audit entries for nothing.GET /tenants/{id}:preview-delete(a GET custom method, AIP-136), withtenants.deleteand[RequireAncestorAdministrator]but no recent MFA. It lists child tenants, devices, outside role assignments and tenants that would be left without an Administrator. The 409s carry the same lists as extension members. Alternatives:?validateOnly=trueon DELETE; a/deletion-previewsub-resource.DeletedTenantstable (no FKs, never exposed) stores the ancestor chain frozen at deletion, and the audit filter uses it. The deleted tenant's name isn't resolved. Alternative: also follow the surviving ancestors' current ancestry. Why: the spec says "the ancestor chain it had at that moment".tenant-deleted, plus a newuser.deleted. Pending transfers go by FK cascade with no entries. Deleted users lose every session and token after the commit, and thosesession.revokedentries are deployment-level. Alternative: end sessions before the commit. Why: a refused deletion must not end anyone's session.:suspend(from Active),:activate(from Suspended or Cancelled) and:cancel(from Active or Suspended), with anything else 409. All needtenants.write. Alternatives: Cancelled as terminal;:resume/:reactivate; a scope per transition. Why: a reversible Cancelled closes no doors, and ADR-0010 has five fixed verbs.status,parentTenantId, wrong casing) are 400, for tenants and devices alike. Alternative: silently ignore output-only fields (AIP-203). Why: never return 200 for a failure, and the error points clients at the actions.Identity, sessions and MFA
signal9-webonly, while global sliding expiry stays on. Idle is judged server-side to the minute (LastSeenAt), and web's cookie ticket is a fixed 12 hours. Alternatives: globalDisableSlidingRefreshTokenExpiration; a per-application setting; a 1-hour sliding cookie. Why: API applications need sliding expiry, and ASP.NET's sliding cookie really ends after 30–60 minutes.SELECT … FOR UPDATElock on the session row serializes refreshes across replicas. Refresh happens 1 minute before expiry, with a 15-second timeout that isn't cancelled by the browser leaving. Every api 401 is held back, refreshed once and retried (bodies buffered). Alternatives: an advisory lock; optimistic concurrency. Why: avoids double rotation, and a lost rotated token would sign the person out.LastSeenAtmigration defaults to 0001-01-01, so sessions open at deploy end at once. Alternative: backfill the current time. Why: those sessions have no refresh token anyway.EndedAt,RevocationDueAt,RevocationAttempts), ignored by every read, until auth confirms revocation with a 2xx. Retries back off from 1 to 30 minutes on a leased claim, and give up 12 h 10 min after sign-in with an error log. Alternatives: a separate pending table; revoke before deleting, which blocks sign-out while auth is down; retry forever.SignInSessionsrow. A consented API application's permanent authorization records only the consent. Alternative: keep tokens on the permanent authorization. Why: logout bysidmust not delete consent, and revoking a shared consent would end every session.openid profile offline_access. Alternatives: check inside the refresh at api; GraphUser.Read. Why: keeps Entra's credential on auth only, and needs no extra permission.iss. Chosen: a request withoutissis refused (400, logged), because asidis unique only per issuer. Alternatives: matchsidacross all directories (weaker); drop front-channel logout and rely on the hourly check. Why: Microsoft documents that Entra sendsiss, though community reports say it doesn't. Deployed proof #1 decides this.ReloadAsync) so OpenIddict's own revocation succeeds. Alternatives: replace OpenIddict'sRevokeTokenhandler; rewrite ID2079 to 200 in a response handler. Why: keeps OpenIddict's handler, but relies on the EF store tracking the token entity within the request.[RequireRecentMfa](DELETE /tenants,RunScript) only with an MFA-approved live grant of the scope and non-secret authentication, otherwise a plain 403.RunScriptalso needsterminal.executefrom the role (Scope catalogue and scope-annotated endpoints #110, Tenant-scoped RBAC core #114). Alternative: let app-only tokens through. Why: ADR-0010 and fail-closed.auth_time. Chosen: web doesn't compare the ID token'sauth_timewith themax_ageit asked for. auth enforcesmax_agebefore issuing the code, and api checks recency on every request. Alternative: add the OIDC Core §3.1.2.1 SHOULD check in web. Why: already enforced twice, and easy to add.roleassignments.read/writeand the grant anti-escalation rule, with noinvitations.*scopes and nousers.writeneeded. Alternative: a newinvitationsfamily in the catalogue. Why: an invitation is a role assignment granted in advance, and ADR-0010 has noinvitationsnoun.prompt=login. Why: KISS and consistent with authorize and consent. Risk: on a shared browser the wrong account can accept.nameclaim. Alternative: move the user to the inviting tenant. Why: ownership changes only by user transfer (Spec: user transfer between owning tenants #93).API applications
ApiApplicationrow (tenant, role, granted scopes, fixed auth method) plus an OpenIddict client (client_id= row ID) whose Properties hold the credentials and RFC 8705 subject metadata (Certificate-bound tokens over mTLS #108). Alternatives: a separate credentials table; a subject column; OpenIddict's singleClientSecret. Why: one source of truth per concern, and rotation with overlap.token_endpoint_auth_methodis set at registration and can't change. A handler refusesclient_assertionfrom applications not registered forprivate_key_jwt. Alternative: any registered credential authenticates. Why: a secret application can never hold MFA-gated scopes, and mTLS applications always get bound tokens.scripts.execute,terminal.executeandtenants.delete. Granting one needs a person with MFA in the last 15 minutes (401 RFC 9470), is 403 for an application caller, and 400 for a secret-authenticated application. Alternative: 409 for the secret case. Why: the method can't change, so the conflict can't be resolved.app-only(role, client credentials) ordelegated(no role, redirect URIs, code + refresh + optional PAR), set at registration. Existing rows are backfilled as app-only. Alternatives: one application allowed both grants; a delegated application with a role cap. Why: the spec describes them separately, and KISS.requirePkce: falseis accepted only withrequireNonce: true, and every such request must be OIDC with a nonce, enforced server-side. All API applications are confidential. Alternative: a singlereplayProtectionfield. Why: RFC 9700 §2.1.1. The dashboard form (fix 16) offers only valid combinations.agent, with no anti-escalation check against whoever registers it. Alternative: limit it to the registering Administrator's own scopes. Why: a delegated token never exceeds the signed-in person, which is checked on every request, and no scope is first-party only.deployment.*still works. Alternatives: let them act at the application's own tenant; refusedeployment.*unless the application is registered at a root tenant.explicitconsent with no admin consent, andwebisimplicit. A single permanent authorization per person and application grows in place (API application refresh caps and connected apps #126).prompt=noneanswersconsent_required. Only grants are audited. Alternatives: admin consent; a new authorization per consent. Why: one connection is one row.no-store. It's unstyled for now. The fresh-sign-in marker is consumed when a code is issued or refused. Alternative: consume it on first read. Why: a Superadministrator answering consent isn't looped back to Entra.connect/paron auth only, optional, and limited by the global limiter only. mTLS-authenticated applications can't use it. Alternative: a PAR endpoint at the mTLS alias. Why: the coordinator default; those clients use the plain authorization request.webgets 14-day idle refresh tokens capped at 90 days by a private claim. Chains issued before this change end at their next use. Alternative: let old chains run on. Why: fail closed.invalid_request, ID2051, on an error page). Alternative: a handler that rewrites it to RFC 6749'sinvalid_scoperedirect./tenants/{t}/api-applications/{a}/connections/{id}(the OpenIddict authorization ID), a subtree view, and a new/api/v1/me/connectionspattern. Revocation is DELETE (204). Alternatives: nest under/users/{id}(a Superadministrator may have no user); top-level/connections;POST :revoke. Why: a revoked consent stops being a connection./meis new to the API.apiapplications.read/writewith no new scope. For your own connections only the token counts (a Viewer can manage theirs). An Administrator reaches connections to applications registered in their subtree, so a client admin below an MSP-registered application can't. There's no bulk revoke (delete the application instead). Alternatives: aconnections.*noun; a bulk:revokeaction.Agents and devices
self_signed_tls_client_auththe self-signature proves possession like a CSR. This deviates from the AC's wording.self_signed_tls_client_authcheck. Chosen: one custom check inSignal9ApplicationManagerfor agents and API applications. The certificate must be byte-for-byte the firstx5cof a registered JWK, its key must match the JWK, and its chain is built trusting only itself, onTimeProvider. OpenIddict's base check is never called. Alternative: add every registered certificate to OpenIddict's trust store. Why: stricter, with one EC path.agentscope,cnf,sub == client_idand a GUID subject. Naming another agent in the URL is 403 (audited). A command result for a device the caller isn't the active agent of is 404. Alternative: identify the agent by URL. Why: ADR-0005.POST /api/v1/agentswith the token in the body, 201 withLocation. An unknown, revoked, expired or used-up token is one 400 message, and an inactive tenant or device limit is 409. A refused enrollment uses nothing up. The audit actor is anonymous plus IP. Alternative: 401 for a bad token. Why: by analogy with OAuthinvalid_grant.GET /tenants/{id}/agentsis the "recently enrolled" view.EnrollmentTokenStatus.UsedUpis added (precedence Revoked > Expired > UsedUp > Active). TheEnrollmentTokenrate-limit policy is keyed by the secret's hash and acquired in the action. Alternative: a dedicated/recently-enrolledresource. Why: follows AGENTS.md's tenant-view convention.Agent:InstanceIdscopes the key name and state folder, andAgent:TenantIdis replaced byAgent:EnrollmentToken. The agent builds its ownHttpClients per certificate instead of the factory's resilience handler. A refused agent keeps retrying with backoff until Spec: agent installers and lifecycle #76. Alternative:HttpClientFactorywith resilience. Why: the certificate goes in the TLS handshake.maxUses: 1(no flag). Rotation keeps the resource and replaces only the secret, with no grace period. There's no DELETE or PATCH, and revoked tokens stay listed. Secrets carry the prefixs9e_(invitations uses9i_). Alternatives: asingleUseflag; new token on rotation or an overlap window; DELETE. Why: retire leaked installers fast, and secret scanners can recognise the prefix.agent_revoked. Chosen: a revoked agent gets a 400 OAuth erroragent_revokedfrom the token endpoint, only after it authenticates. Alternatives:invalid_client(401); deleting the client. Why: the agent can tell revocation from a bad certificate, and nobody can probe whether an agent is revoked.POST /agents/{id}:revokewithdevices.write(Technicians). The device's active agent is cleared (the device goes Offline), and the device getsRequiresAgentApprovalfor its next agent. Alternatives:devices.delete; keep a revoked agent as active with a Revoked status. Why: story 101 says Technician, and a revoked agent isn't active.PUT /agents/{id}/certificate(agent only; GET withdevices.read). The old certificate, its tokens and its hub connection are cut off at once. The agent rotates 120 days before its 365-day expiry, using a two-slot key store that survives a lost response. Alternatives: a:rotateaction; let old tokens run 10 minutes. Why: a rotation after a suspected leak must take effect at once.AgentStatus(Active, Revoked, AwaitingApproval, plus Agent identification #131's NeedsDecision).POST /agents/{id}:approveneedsdevices.write. Held agents heartbeat (no work), see empty pending commands, get 409 on results, and may connect to the hub. Alternatives: anagents.approvescope; refuse the hub until approved. Why: ADR-0010 has five verbs, and the hub lets approval ring the agent promptly.DeviceId = nulland aPendingIdentificationrow, and the device is created or chosen only on decision (AgentDto.DeviceIdis nullable). It counts toward no device limit, and telemetry is 409. Alternative: a provisional device merged on Reinstalled. Why: avoids phantom devices, moved history and audit entries pointing at deleted devices.POST /agents/{id}:identify {decision: Reinstalled | KeepBoth, deviceId?}andGET /agents/{id}/candidate-devices. The queue is?status=NeedsDecision. Reinstalled onto a device whose agent is active revokes that agent. Alternatives::rebindand:keep-bothverbs; a separate flag or resource. Why: one transition named after the glossary term.OsInstalledAtand the hardware IDs can be forged with a leaked installer.InstallDateon Windows andstatxbirth time on Linux, never .NET's creation time. A time unknown, in the future, or ≥ 24 h old isn't fresh. Residual risks: a person can still approve a forged claim, several claims can coexist, and Windows feature updates resetInstallDate(harmless, since claims are held). Alternative: no auto-resolve.Real-time
LISTEN/NOTIFY; Azure'sCloseConnectionAsync; per-event checks. Why: auth and web share only the database, and the check also covers scale-out.deploymentgroup. Events go to the owner's group and every ancestor's, read live, so transfers need no group change. A caller with no hub scope at any tenant passes negotiate and is closed at start. Alternatives: join every assigned tenant (duplicates); 403 at negotiate.DeviceChanged/DeviceDeleted({deviceId, url}),CommandChanged({deviceId, commandId, url})andTelemetryReceived({deviceId, sampleId, url}), with no tenant. Device transfers sendDeviceChangedto both sides. Alternatives: one genericChanged({kind, url});DeviceDeletedto the former side. Why: small notices, with details fetched through the BFF (ADR-0009).Origincheck on web. Chosen: WebSocket handshakes to/hubs/dashboard*must carry web's exact origin, and any hub request that carriesOriginmust match. Requests without one pass only if they aren't handshakes. Mismatches get 403. Alternative: requireOriginon every hub request. Why: the cookie risk exists only at the BFF, and requiring it would break same-origin long polling and SSE.id, so clients stay unaware of the hosting mode. Alternatives: clients setSkipNegotiation(they'd need to know the mode); sticky sessions. Why: no client or contract change. The cost is emulating SignalR's negotiate response.{web}/hubs/dashboard/service/client/...and forwards only that path to the service. Agents connect to the service directly. Alternatives: two SDK endpoints with a custom router; allow the service origin in web's CSP. Why: keeps the no-third-party-origin CSP. ADR-0009 rejects proxying through api only.sub,client_id,cnf,oi_tkn_id, the hub scopes and the Superadministrator role, with a default lifetime of 2 minutes (30 s–10 min). The browser can read this token. Alternative: the SDK default of 1 hour. Why: it's used only to open the connection, and reconnects negotiate again.Realtime:HostingisAzureSignalR(publish default) orSelfHosted(run default).Realtime:ApiReplicas > 1adds a Redis container and replicas. On Azure SignalR, api refuses direct hub connections (404) except negotiate. Alternative: allow direct connections. Why: a client on api's local connection would never hear a doorbell.API shape and HTTP
client_idand IP together. AGlobalper-IP policy can cover every request, including OpenIddict's own endpoints (revocation and introspection get onlyGlobal), but it applies no limit untilPermitLimitis configured; only the three defaults in item 141 are on out of the box. Fixed window, with no queueing and 429 plusRetry-After. Alternatives: chained per-client and per-IP limits; placeholder endpoints; sliding window or token bucket. Why: the literal reading of the spec, and simplest.[RequireScope]. Chosen: a small custom attribute onIAuthorizationRequirementData. Several scopes in one attribute mean any one, several attributes must all hold. A 403 carries RFC 6750insufficient_scope. Alternatives: Microsoft.Identity.Web[RequiredScope](reads Entra'sscp); one policy per scope (can't say "or"). Why: the dashboard hub needs any-of, and the RBAC filter reuses the requirement.agent.GET /agents/{id}needsdevices.read(there's no agents family), cancelling a command needscommands.execute, andRunScriptalso needsterminal.execute. Alternative: anagentsscope family. Why: agents are part of a device's record./docsand proxies only/docs/openapi/**to api. The document moves to/docs/openapi/v1.jsonwith no redirect, and its server is the relative/. Alternative: api serves Scalar and web proxies the page. Why: the page lives under web's CSP, api serves no HTML, and "Try it" uses whichever host served the document./docsadds a per-request nonce toscript-srcandstyle-src-elemonly. "Try it" sends web's CSRF header through a same-origincustomFetch, and tokens typed into Scalar are dropped. Alternatives: hashes (nothing stable to hash);'unsafe-inline'. Why: CSP3's strict-CSP pattern. Scalar's toasts render unstyled.Audit
TenantScopeBypassvalue declares[Audited]or[NotAudited(reason)], enforced by a test. Audited bypasses write a deployment-leveltenant-scope.bypassedentry per use; request-time bypasses confined to the caller or guarding an audited action aren't audited. Alternative: audit every bypass once per request (an entry per heartbeat). Why: story 70 names background jobs.[NotAudited]:AgentSelfService,CallerRoleAssignments,CallerApiApplication,AuditPlacement,AdministratorLockoutGuard,UserRoleAssignmentGuard,SessionCheck,InvitationRedemption,Enrollment,TenantTransferParties,TenantTransferMove,TenantTransferDuplicateDevices,DeviceTransferHardwareCheck,CallerConnections,InheritedTenantSettings,TenantDeletion,RoleAssignmentHolder,HeldClaimFacts. All 18 values are not audited; no audited bypass exists yet. Alternative: audit any of them. Why: each reads only the caller's own data or yes/no, and the action it serves is audited.GET /audit-entries(+/{id}), read-only, no filters yet. One table with no FKs, ordered by a database sequence and append-only by trigger (future pruning needs a migration). Names resolve at read time through the reader's filter. Client IP is stored. Alternatives: a tenant view; append-only by convention. Why: entries outlive what they mention. IP retention is undecided.Dashboard
deployment.*stays API-only. Alternatives: move the catalogue into Contracts; add a scopes endpoint. Why: the full catalogue is in Server, which the WASM client can't reference.navigator.clipboard.writeText), tenant transfers (new parent entered as a tenant ID), device transfer dialog, agent approvals, connections, and live Devices updates. Every tenant or user picker lists only the first 100 (paging TODO). Alternative: paged pickers now. Why: KISS for this spec.Core-principles review
minimumVersion/maximumVersion), and OAuthunsupported_agent_versionat the token endpoint. Alternatives: 426 (for switching protocols, needsUpgrade); 403 (reserved forinsufficient_scope); 409. Why: RFC 9110 §15.5.1; Azure answers an unsupportedapi-versionthe same way.docs/agent-protocol.md#unsupported-agent-versionon GitHub. Alternatives: atag:URI; a future docs domain. Why: RFC 9457 wants a resolvable type, and it's one constant inAgentProtocol.major.minor.0up to api's own version, inclusive, configurable withAgentVersions:Minimum/Maximum. Alternatives: exact match (any api patch strands every agent until Spec: agent installers and lifecycle #76's updater exists); no maximum. Why: API changes drive supportability.Restart=retry in a loop; Spec: agent installers and lifecycle #76's updater builds on this state.FreshSignInsrow; the cookie holds only its ID, and the authorization endpoint deletes the row on use, issuing a Superadministrator's or re-authenticated code only if that delete wins. Unused rows expire after 10 minutes (the cookie flag lasted 12 hours). Alternatives: a server-side ticket store; keep the replayable cookie flag.promptandmax_age. Chosen:prompt=nonenever redirects (login_required);prompt=loginre-authenticates at Entra, proved by a random attempt ID carried through Entra, and refused (access_denied, auditedNotFresh) if Entra'sauth_timepredates the request by over 1 minute. Alternatives: forwardprompt=noneto Entra; a clock tolerance onmax_age. Why: OIDC Core §3.1.2.1/§3.1.2.6; fixesmax_age=0.RSA-OAEP(SHA-1) +A256CBC-HS512: IdentityModel 8.19.2 (used by OpenIddict 7.7.2) has noRSA-OAEP-256. Nothing weakened; RFC 7518 §4.3 rates it Recommended+, but it differs from item 1. Options: (a) accept until IdentityModel adds it; (b) a customICryptoProviderwith OAEP-SHA-256 (custom crypto to own); (c) symmetricA256KWshared by Signal9's hosts (new key management).self_signed_tls_client_authcertificate policy for agents and API applications: self-signed, not a CA, digitalSignature + clientAuth, ECDSA P-256/384/521 or RSA ≥ 3072, SHA-2, valid now, ≤ 398 days, self-signature verified (the old check never verified it). Alternative: also enforce key size and lifetime at authentication. Nothing shipped, so no registration is affected.WWW-Authenticate: Cookie realm="signal9", login="/bff/login"on both of web's 401s. Alternatives:Bearer(wrong, the browser holds no bearer token); an ADR recording a deviation. Why: RFC 9110 §15.5.2 makes the header a MUST, and no registered scheme fits cookie sessions.If-None-Match: *is 412 andIf-Match: *goes ahead, including the POST-only enrollment and telemetry targets. Alternative: treat POST-only targets as having no current representation, like the heartbeat. Why: RFC 9110 §9.3.3 and §13.1, one rule for clients. It supersedes ETag and If-Match concurrency for resources #137's "If-Match on a create always fails".Token300/min per (client_id, IP),Enrollment60/min per IP,InvitationRedemption30/min per IP, fixed windows. Alternatives: lower values (web refreshes every person's tokens as one client);Tokenper IP alone (one bucket for the dashboard and a NAT'd site's agents). Gap: rotatingclient_idfrom one address gets fresh buckets untilGlobalis configured.Hubs:AccessCheckInterval. Alternatives: an agent-keyed index; one sweeper per hub.PeriodicJobstarts anActivity(sourceOSI.Signal9.BackgroundJobs) around each run, and tests wait on it. Alternatives: poll outcomes on the real clock; a test-only hook. Why: it's a test seam an operator can also use.EntraRefresh.ForceAsynctreats any failure but host shutdown as "ask again next run"; a Polly timeout used to abort the whole run, leaving later accounts unchecked for an hour. Found by the fake-clock tests.RestartMachine/RestartService,CommandPriority,SubscriptionPlan/Tenant.Plan,Device.MaintenanceMode,IsMutualTls; the GLOSSARY's "Maintenance mode" stays.RunScriptstays: it carriesterminal.executeand its step-up, covered by Spec: auth, tenancy and agent identity target #63's tests. Alternatives: keepPlanwithBasic; drop the glossary entry; removeRunScriptuntil Spec: command reliability, scripts and signing #82.InitialCreate, with the audit log's append-only trigger carried over by hand. The old closure backfill and clean-up deletes are dropped, because they only served databases older than this branch. Why: nothing has shipped; your decision.Deferred / follow-up tickets needed
users.writecovers it, which needs the story-27 session cut-off and the story-16 rule.AddPostgresbranch for self-hosted (Compose) publishing. Also: Container Apps ingressclientCertificateMode(apiaccept, authignore; Certificate-bound tokens over mTLS #108), the Key Vault KEK and wrap/unwrap grant (Signing and encryption keys stored in the database, wrapped by the key-encryption key #106), auth's managed identity and federated credential (Entra sign-in at the auth host #109), writingEntra:DeployingTenantId(Superadministrator from the Entra app role #113), and the migration service.sid(andiss) in front-channel logout and ID tokens for multi-tenant sign-ins (decides item 62). Dev #5, whether Container Apps ingress replaces a client-suppliedX-Forwarded-Client-Cert, itsCertencoding, and the peer IP forForwardedHeaders:KnownNetworks.agent_revokedinstead of retrying, side-by-side installation, the "Update required" status, and re-enrollment.RunScript(Separate devices from agents #102, Scope catalogue and scope-annotated endpoints #110).monitors,alertsandwebhooksscopes, and anonymous device records (exclude them from the deletion block and add them to the cascade; Tenant deletion #134).reportschedulesscope.users.transferis in the catalogue).typ: client-authentication+jwt,aud= issuer; App-only API applications #124).Task.Delays (AgentDoorbellTests,AgentApprovalTests,TenantTreeTests), noted out of scope in the fix pass.JsonElement, so clients can't discover the editable fields ofUpdateTenantRequestandUpdateDeviceRequest.LiveSession.TokenIdshas no production reader, and the agent's 409 comment on command results ("or already reported") now applies only to a different report.ObjectDisposedExceptionfromRSAOpenSslinStoredKeys.Load, and one 100 s stall inTenantTransferTests.A_decided_transfer_cant_be_decided_again.Closes #63 Closes #100 Closes #101 Closes #102 Closes #103 Closes #104 Closes #105 Closes #106 Closes #107 Closes #108 Closes #109 Closes #110 Closes #111 Closes #112 Closes #113 Closes #114 Closes #115 Closes #116 Closes #117 Closes #118 Closes #119 Closes #120 Closes #121 Closes #122 Closes #123 Closes #124 Closes #125 Closes #126 Closes #127 Closes #128 Closes #129 Closes #130 Closes #131 Closes #132 Closes #133 Closes #134 Closes #135 Closes #137 Closes #143 Closes #144
🤖 Generated with Claude Code