Skip to content

feat: auth, tenancy and agent identity (#63) - #136

Open
MWG-Logan wants to merge 72 commits into
devfrom
integration/63-auth-tenancy
Open

MWG-Logan wants to merge 72 commits into
devfrom
integration/63-auth-tenancy

Conversation

@MWG-Logan

@MWG-Logan MWG-Logan commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

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 --> pg
Loading
OSI.Signal9.Auth/         # authorization server: Entra sign-in, consent, invitations, session checks, front-channel logout
OSI.Signal9.API/          # resource server: tenants, transfers, RBAC, users, API applications, agents, devices, audit, hubs
OSI.Signal9.Server/       # shared data (EF Core + Npgsql, one InitialCreate), TenantScope filter, OpenIddict stores, keys
OSI.Signal9.Web(.Client)/ # BFF host + Fluent UI v5 dashboard (built from scratch, steamrolling the old UI)
OSI.Signal9.Agent/        # enrollment, mTLS identity and rotation, hardware identifiers, doorbell; per-OS platforms (Windows, Linux; macOS stubbed), version paired to api
OSI.Signal9.*Tests/       # IntegrationTests (Testcontainers), Web.Tests (bUnit), Agent.Tests

Every 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

  • Before: 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-changes clean, and 943/943 tests pass (IntegrationTests on PostgreSQL via Testcontainers, Web.Tests in bUnit, Agent.Tests).
  • The final code review (standards and spec axes, by Astra) found 16 distinct issues (10 standards, 8 spec, 2 found by both). All are fixed with failing-first regression tests, except the two deferred to Spec: deployment, releases and first-run setup #77 and one optional page-boilerplate cleanup. A second review against the OSI tenets and security/API practice (Astra and Opus) found 27 more; both fix passes are merged (items 131–147 below). For example:
test Forwarded_headers_are_ignored_when_no_proxy_network_is_configured
  given  ForwardedHeaders:KnownNetworks = []          # the shipped default
  when   a request sends X-Forwarded-For: 203.0.113.9
  then   RemoteIpAddress is the socket peer           # before: 203.0.113.9 (spoofable)

test Superadministrator_comes_only_from_the_deploying_directorys_session
  given  one home account signed in through the deploying directory and as a guest elsewhere
  when   the hourly Entra check runs
  then   only the deploying directory's session is a Superadministrator
                                                      # before: the guest session inherited the flag

Merge Danger

Door: one-way

The schema is replaced (one new InitialCreate on 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 on dev's old shapes survives.

Blast Radius: everything

Every endpoint now needs a token and tenant scope; agents and API clients written against dev stop working; operators must configure Entra (deploying directory, auth_time optional 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)

  • Tenant depth is zero-indexed. The root is depth 0, depth is the closure distance from the root, MaxDepth 20 allows depth ≤ 20, and MaxDepth may be 0 (roots only).
  • Agents: a machine can run several independent agent instances, one per (deployment, root tenant tree). Within one root tree a machine is one device with at most one active agent.
  • Real-time hosting: on Azure the hubs always use Azure SignalR Service. Redis serves only self-hosted scale-out, so Azure Managed Redis isn't wired.
  • Certificate-bound tokens over mTLS #108: a custom HeaderConverter reads Container Apps' X-Forwarded-Client-Cert (Cert only; a header with more than one element is refused). The built-in converter can't parse that format.
  • Root-wide identification with fresh-OS auto-resolve. This replaces Agent identification #131's subtree-only candidates. Candidates span the whole root tree, and different root trees never match. An OS installed less than 24 h before enrollment, with exactly one match, rebinds automatically. The safeguard the coordinator added on top is item 107.
  • Core-principles review (2026-10-26): pair agent and api versions now, with a defined refusal for unsupported agents; add per-OS seams and the multi-OS fixes now; set default rate limits on three endpoints (token, enrollment, invitation redemption); squash the branch's migrations into one InitialCreate.

Hosting and operations

  1. 🔒 Signing and encryption keys stored in the database, wrapped by the key-encryption key #106 Data Protection key encryption. Chosen: keep the custom KEK envelope on Data Protection's IXmlEncryptor extension point (AES-256-GCM data key per key, RSA-OAEP-256 wrap, Key Vault kid pinned), and fail closed (no auto-generated key) when no stored key decrypts on this host. Alternatives: the built-in ProtectKeysWithCertificate/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 no kid pinning, which is below the standards floor.
  2. Signing and encryption keys stored in the database, wrapped by the key-encryption key #106 ring unreadable at start. Chosen: if the database is unreachable or not migrated at host start, keep Data Protection's default (it generates keys) and log a warning. Alternative: fail closed there too. Why: hosts can start before migrations run. Residual risk: a host with the wrong KEK that starts during an outage can add a key once the database returns.
  3. Key rotation and reload without restart #107 key retention. Chosen: retired signing and encryption keys are kept for 90 days (the absolute refresh-token cap), then deleted. Each host reloads keys on a timer computed from the stored keys. Alternatives: 14-day retention; keep keys flagged; poll the table or use LISTEN/NOTIFY. Why: the bound holds for any configured token lifetime, and a deterministic schedule adds no moving parts.
  4. Hardening baseline for the hosts #105 health probes. Chosen: /alive and /health are served only on a separate HealthProbes: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.
  5. Hardening baseline for the hosts #105 HSTS. Chosen: max-age=63072000; includeSubDomains; preload on 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.
  6. Certificate-bound tokens over mTLS #108 TLS client certificates. Chosen: Kestrel on api allows but doesn't require client certificates, and OpenIddict validates them. Trusted CAs for tls_client_auth are 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.
  7. Certificate-bound tokens over mTLS #108 proof-of-possession seam. Chosen: one class (ProofOfPossession) configures the accepted proofs and token binding, and enforcement is OpenIddict's built-in ValidateProofOfPossession. 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.
  8. Superadministrator from the Entra app role #113 deploying directory setting. Chosen: auth refuses to start without a valid Entra:DeployingTenantId. Alternative: make it optional and fail closed, so nobody is a Superadministrator. Why: a misconfiguration fails loudly at startup instead of silently.
  9. Recent-MFA step-up #120 Entra configuration for operators. Chosen: sign-ins without the auth_time optional 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.
  10. New dependencies (all MIT). Fluent UI Blazor 5.0.1, bUnit 2.12.5, Yarp.ReverseProxy 2.3.0 and Microsoft.AspNetCore.Authentication.OpenIdConnect 10.0.13 (Web host becomes the backend-for-frontend #112); Microsoft.Azure.SignalR 1.33.1, SignalR.StackExchangeRedis 10.0.13, Aspire.Hosting.Azure.SignalR/Redis 13.6.3 and Testcontainers.Redis 4.16.0 (Real-time hosting chosen by configuration #133); Scalar.AspNetCore 2.18.3 (Public API reference with Scalar #135).

Dashboard host (web)

  1. Web host becomes the backend-for-frontend #112 web's shape. Chosen: a Blazor Web App host plus a WebAssembly client, WebAssembly render mode only, no prerendering. The server renders only the document. Alternatives: standalone WASM served with 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.
  2. 🔒 Web host becomes the backend-for-frontend #112 web's tokens and client certificate. Chosen: web redeems codes at api's mTLS alias with self_signed_tls_client_auth, so its tokens are certificate-bound and it has no secret. Its certificate is a StoredKey (new use WebClient, 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.
  3. Web host becomes the backend-for-frontend #112 session storage. Chosen: the __Host- cookie holds only an encrypted ticket with sub and a session ID. Tokens live in a DashboardSessions table, protected per session, so any replica can serve the session and deleting the row defeats a replayed cookie. Alternative: an ITicketStore that holds the whole ticket. Why: same effect with fewer moving parts.
  4. Web host becomes the backend-for-frontend #112 CSRF. Chosen: every state-changing request to web must carry 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).
  5. Web host becomes the backend-for-frontend #112 CSP. Chosen: a strict policy (style-src-elem 'self', 'unsafe-inline' only on style-src-attr, img-src 'self' data:). Fluent UI's FluentLayout inline <style> is blocked, and app.css repeats its mobile rules. Alternatives: 'unsafe-inline' on style-src-elem, or dropping FluentLayout. Why: keeps the standards floor.
  6. Web host becomes the backend-for-frontend #112 proxy allow-list and CORS. Chosen: YARP forwards only /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 use response_mode=query, and PAR is off for web. Alternative: keep CORS for direct dashboard-to-api calls. Why: the browser never holds a token.
  7. Web host becomes the backend-for-frontend #112 certificate chain time. Chosen: client-certificate chains are checked on the hosts' TimeProvider (a cloned OpenIddict chain policy). Alternative: the real clock. Why: makes rotation testable. Production behaviour is unchanged.

Data and tenancy

  1. Tenant filter over the closure table #111 tenant filter design. Chosen: an EF Core named query filter that runs EXISTS over 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.
  2. 🔒 Tenant filter over the closure table #111 bypass mechanism. Chosen: bypasses are values of a TenantScopeBypass enum, used only through IgnoreTenantScope(reason) and tagged into the SQL. An IL-scanning architecture test fails on any other IgnoreQueryFilters. 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.
  3. 🔒 Tenant-scoped RBAC core #114 per-endpoint tenant filter. Chosen: the filter admits only tenants where the caller's role grants the endpoint's scope and the token carries it. Everything else reads as missing (404 for items, 400 for body references), so a Viewer gets 404, not 403, on PATCH. Alternatives: filter by every assignment and check the scope per action (403); 403 when the scope is granted nowhere. Why: central and fails closed.
  4. Tenant-scoped RBAC core #114 role assignment cache. Chosen: role assignments are read once per request, with no cross-request cache. Alternative: a short timed cache. Why: revocation takes effect on the next request, and no invalidation across replicas is needed.
  5. Tenant-scoped RBAC core #114 references outside reach. Chosen: a parent tenant outside the caller's reach on POST /tenants is 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: /tenants exists, and a different answer would reveal that the tenant exists.
  6. Tenant-scoped RBAC core #114 roles are deployment-wide. Chosen: GET/PATCH/DELETE /roles need 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.
  7. 🔒 Tenant-scoped RBAC core #114 user identity uniqueness. Chosen: one User per 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.
  8. Tenant-scoped RBAC core #114 cascades. Chosen: deleting a tenant cascades its users and their role assignments, deleting a user cascades their assignments, and roles are Restrict. Alternative: block or soft-delete. Why: matches the spec's tenant deletion.
  9. Tenant-scoped RBAC core #114 person or application token. Chosen: a token names a person when its sub is {tid}:{oid} and differs from client_id (PersonSubject, shared by auth and api). Alternative: a dedicated claim. Why: works with the existing token contents (ADR-0007).
  10. 🔒 Superadministrator from the Entra app role #113 Superadministrator re-read. Chosen: auth's session is marked fresh only straight after Entra, and each token issuance consumes that mark, so the next issuance sends the person back to Entra's SSO. The hourly check (Session checks, sign-out and Entra front-channel logout #119) also re-reads the role. Alternatives: trust the cookie for its lifetime; read Graph at each issuance; force an MSAL refresh. Why: meets story 59 with no new permissions.
  11. Superadministrator from the Entra app role #113 how a Superadministrator meets the filter. Chosen: IAssignedTenants.IsSuperadministrator makes TenantScope keep 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.
  12. Superadministrator from the Entra app role #113 deployment.*. Chosen: deployment.* scopes count only for a Superadministrator, enforced in RequireScopeHandler. A token that has the scope but no Superadministrator gets a plain 403 without insufficient_scope. Alternative: rely on role scopes alone. Why: asking for the scope again can't help (RFC 6750 §3.1).
  13. 🔒 Role assignment management and Administrator protections #116, Invitations #121, App-only API applications #124 anti-escalation. Chosen: you can grant a role, invite to it or give it to an API application only if its scopes fit your role scopes at the tenant ∩ your token's scopes. Superadministrators skip the check. Alternative: compare with role scopes only. Why: the spec defines effective access as token ∩ role ∩ subtree, and role-only checks could never refuse anything with the built-in roles.
  14. Role assignment management and Administrator protections #116 immutable role assignments. Chosen: no PATCH (405). A downgrade is a revoke plus a grant. Alternative: merge-patch roleId with the same guards. Why: KISS, and Azure RBAC's shape (ADR-0010).
  15. 🔒 Role assignment management and Administrator protections #116 Administrator protections. Chosen: revoking a peer's Administrator assignment needs an Administrator at a strict ancestor (403). "Last Administrator" (409) means no other Administrator at the tenant or above. A Superadministrator may remove the last one. Revoking your own is allowed unless you're the last. Alternative: refuse everyone, Superadministrators included. Why: the same rule as the tenant-deletion guard.
  16. Role assignment management and Administrator protections #116 [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.
  17. Role assignment management and Administrator protections #116, Device transfer between tenants #123, Tenant deletion #134, fix: one global tenant-tree lock. Chosen: role-assignment revocation, device transfers and the tenant-deletion guard run under TenantTree.LockKey inside 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.
  18. 🔒 Users management #117 user edits. Chosen: only displayName is editable, and the Entra identity and owning tenant are read-only (400). Editing a user who holds role assignments needs roleassignments.write at 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.
  19. Agent approval hold and agent status #130 approval hold. Chosen: holdsNewAgentsForApproval is a tenant field (PATCH, tenants.write). The DTO shows the effective value plus approvalHoldInherited. 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/:release actions; accepting the change silently. Why: it's a setting, not a lifecycle state.
  20. Tenant transfers #122 tenant transfer API shape. Chosen: /tenants/{id}/transfers/{transferId} with :accept, :decline and :cancel, and a tenant view filtered by direction, status and includeDescendants. 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.
  21. Tenant transfers #122 :cancel added. 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.
  22. Tenant transfers #122 who sees a transfer. Chosen: anyone who reaches the transferred tenant, its parent when proposed, or the new parent. Both sides see the live names of the tenant and the new parent through a not-audited bypass. The former parent is shown by ID only. Alternatives: IDs only; names snapshotted at proposal. Why: the receiver must know what it's accepting.
  23. Tenant transfers #122, Device transfer between tenants #123 one device per machine per root tree on transfers. Chosen: checked only for moves that change root tree, under the lock: at acceptance for tenant transfers (the 409 lists device ID pairs only), and for device transfers (409 that never names the other device). Alternatives: check every move, or also at proposal. Why: a move within one tree brings no new machine, and Keep both may legitimately leave duplicates.
  24. 🔒 Device transfer between tenants #123 the hardware check can reveal that a machine exists. Chosen: the device-transfer check reads the whole destination root tree past the filter, so a refused caller learns that some device for that machine exists there, even in parts of the tree they can't see. Alternative: check only devices the caller can see, which would allow duplicates. Why: the rule can't be enforced otherwise.
  25. 🔒 Tenant transfers #122, Device transfer between tenants #123 no recent MFA on transfers. Chosen: tenant transfers need [RequireAncestorAdministrator] but no recent MFA. Device transfers need only devices.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.
  26. Tenant transfers #122 same person on both sides. Chosen: someone with authority on both sides can propose and accept their own transfer. Alternative: require a different person to accept. Why: nothing in the spec asks for two people.
  27. Tenant transfers #122 transfer foreign keys. Chosen: a transfer is deleted with the tenant it moves or with its new parent. The former parent may be deleted after acceptance (set to null). Alternative: keep transfer rows. Why: the audit trail survives either way.
  28. Device transfer between tenants #123 other device-transfer conflicts. Chosen: transferring to the device's own tenant, or into a tenant at its 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.
  29. Tenant deletion #134 deletion preview. Chosen: GET /tenants/{id}:preview-delete (a GET custom method, AIP-136), with tenants.delete and [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=true on DELETE; a /deletion-preview sub-resource.
  30. 🔒 Tenant deletion #134 outside role assignments in the preview. Chosen: the preview shows the deleting Administrator the assignments that the deleted tenant's users hold elsewhere, even in other trees, as IDs only. Alternative: show only assignments within the caller's reach. Why: the spec requires showing what the cascade removes, and "whoever controls a user's owning tenant controls that user".
  31. Tenant deletion #134 deleted tenants' audit trail. Chosen: a DeletedTenants table (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".
  32. 🔒 Tenant deletion #134 cascade and sessions. Chosen: each cascaded removal is audited where it was, with detail tenant-deleted, plus a new user.deleted. Pending transfers go by FK cascade with no entries. Deleted users lose every session and token after the commit, and those session.revoked entries are deployment-level. Alternative: end sessions before the commit. Why: a refused deletion must not end anyone's session.
  33. Tenant deletion #134 "live devices". Chosen: every device blocks deletion until anonymous device records arrive (Spec: monitors, alerts and telemetry retention #89). Agents awaiting identification don't block and are deleted with their OAuth clients. Alternative: define liveness by last check-in now. Why: the anonymous-record concept isn't built yet.
  34. Align tenant updates and the agent executor with AGENTS.md conventions #144 tenant lifecycle. Chosen: :suspend (from Active), :activate (from Suspended or Cancelled) and :cancel (from Active or Suspended), with anything else 409. All need tenants.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.
  35. Align tenant updates and the agent executor with AGENTS.md conventions #144 strict merge patch. Chosen: patches naming non-editable or unknown members (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

  1. Dashboard session refresh and session caps #118 web's 12-hour session. Chosen: web's refresh tokens get a fixed 12 hours, set per token for signal9-web only, 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: global DisableSlidingRefreshTokenExpiration; 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.
  2. 🔒 Dashboard session refresh and session caps #118 idle at auth. Chosen: auth's own cookie is 1 hour sliding, plus a 12-hour cap from the Entra sign-in, which is how story 49 is met. auth doesn't refuse an idle dashboard refresh itself; web enforces idle and revokes. Alternative: auth also refuses a dashboard refresh token whose last rotation is over 1 hour old. Why: the spec gives idle enforcement to web.
  3. Dashboard session refresh and session caps #118 refresh mechanics. Chosen: a SELECT … FOR UPDATE lock 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.
  4. Dashboard session refresh and session caps #118 sessions at deploy. Chosen: the LastSeenAt migration 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.
  5. 🔒 Fix: durable sign-out revocation. Chosen: ended dashboard sessions stay as rows (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.
  6. Session checks, sign-out and Entra front-channel logout #119 a session is one ad-hoc authorization per code. Chosen: each issued code gets its own OpenIddict authorization plus a SignInSessions row. A consented API application's permanent authorization records only the consent. Alternative: keep tokens on the permanent authorization. Why: logout by sid must not delete consent, and revoking a shared consent would end every session.
  7. Session checks, sign-out and Entra front-channel logout #119 hourly Entra check. Chosen: a background job on auth (every 5 minutes, a session is due 55 minutes after Entra last vouched). The MSAL token cache moves to the database, encrypted. The forced refresh asks only for openid profile offline_access. Alternatives: check inside the refresh at api; Graph User.Read. Why: keeps Entra's credential on auth only, and needs no extra permission.
  8. 🔒 Session checks, sign-out and Entra front-channel logout #119 what ends a session. Chosen: MSAL finding no refresh token counts as fatal (fail closed). Ended Entra sessions are timestamped and kept 90 days. Every refresh checks that the session exists, its Entra session hasn't ended, and the person holds a role assignment (or is a Superadministrator). Removing the last assignment revokes sessions at once. Alternative: treat a missing cache entry as transient.
  9. 🔒 Session checks, sign-out and Entra front-channel logout #119 explicit sign-out leaves auth's cookie. Chosen: signing out revokes every token but doesn't end auth's own sign-in cookie (≤ 1 h idle) or Entra's session, so signing in again within the hour skips Entra. Alternative: OIDC RP-initiated logout from web to an end-session endpoint on auth and on to Entra. Why: the spec asks for token revocation, and Entra's SSO would behave the same.
  10. 🔒 Session checks, sign-out and Entra front-channel logout #119 front-channel logout requires iss. Chosen: a request without iss is refused (400, logged), because a sid is unique only per issuer. Alternatives: match sid across all directories (weaker); drop front-channel logout and rely on the hourly check. Why: Microsoft documents that Entra sends iss, though community reports say it doesn't. Deployed proof #1 decides this.
  11. 🔒 Fix: Entra answering for someone else. Chosen: if the refreshed ID token's subject doesn't match the session, the session ends (audited, "Entra answered for someone else"). Sessions are grouped by home account and sign-in directory. Alternatives: treat it as unavailable and retry; ignore it. Why: retrying could keep an unconfirmed person signed in indefinitely, and the mismatch isn't transient.
  12. Fix: session-check ordering. Chosen: the hourly check ends sessions before recording the Entra session as ended. Alternative: the reverse order. Why: the refresh in between then reports the check's reason. Either order is safe, because the per-refresh check catches a session started meanwhile.
  13. Fix: ID2079 revocation race. Chosen: Signal9's handler runs before OpenIddict's. It revokes the authorization and its tokens, then re-reads the token row through EF (ReloadAsync) so OpenIddict's own revocation succeeds. Alternatives: replace OpenIddict's RevokeToken handler; 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.
  14. 🔒 Recent-MFA step-up #120, App-only API applications #124, fix: MFA-gated actions for applications. Chosen: an application acting as itself passes [RequireRecentMfa] (DELETE /tenants, RunScript) only with an MFA-approved live grant of the scope and non-secret authentication, otherwise a plain 403. RunScript also needs terminal.execute from 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.
  15. Recent-MFA step-up #120 the BFF doesn't re-check auth_time. Chosen: web doesn't compare the ID token's auth_time with the max_age it asked for. auth enforces max_age before 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.
  16. Invitations #121 invitation scopes. Chosen: invitations use roleassignments.read/write and the grant anti-escalation rule, with no invitations.* scopes and no users.write needed. Alternative: a new invitations family in the catalogue. Why: an invitation is a role assignment granted in advance, and ADR-0010 has no invitations noun.
  17. Invitations #121 redemption flow and codes. Chosen: GET shows the offer and POST (with antiforgery) accepts it. Unknown is 404; used, expired or revoked is 410 before sign-in; another directory or person is 403; a role already held is 409. No refusal uses the invitation up. Revoking an expired invitation is allowed. Alternative: redeem on GET after sign-in. Why: link scanners and prefetchers can't use the link up.
  18. 🔒 Invitations #121 redemption reuses an existing auth session. Chosen: if the browser already has a sign-in at auth, redemption uses it; only otherwise does it go to Entra. The page names the account before Accept. Alternative: always send the person to Entra, optionally with prompt=login. Why: KISS and consistent with authorize and consent. Risk: on a shared browser the wrong account can accept.
  19. 🔒 Invitations #121 creator's access isn't re-checked at redemption. Chosen: an invitation stands once made, until it's revoked or expires (72 h), even if its creator has lost the access that allowed it. Alternative: re-check the creator's role scopes at redemption. Why: KISS. Revoking the creator doesn't withdraw their open invitations.
  20. Invitations #121 ownership on redemption. Chosen: a new user is owned by the invitation's tenant. An existing user keeps their owning tenant and just gains the assignment. The display name comes from Entra's name claim. Alternative: move the user to the inviting tenant. Why: ownership changes only by user transfer (Spec: user transfer between owning tenants #93).

API applications

  1. App-only API applications #124 data model. Chosen: an ApiApplication row (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 single ClientSecret. Why: one source of truth per concern, and rotation with overlap.
  2. App-only API applications #124 role plus explicit scopes. Chosen: an app-only application has a role (its upper bound) and an explicit scope list within it. The scope list is what "granting an MFA-gated scope" means. Alternative: role only, with MFA-gated scopes implicitly excluded. Why: makes each grant explicit, auditable and refusable per scope.
  3. 🔒 App-only API applications #124 fixed authentication method. Chosen: token_endpoint_auth_method is set at registration and can't change. A handler refuses client_assertion from applications not registered for private_key_jwt. Alternative: any registered credential authenticates. Why: a secret application can never hold MFA-gated scopes, and mTLS applications always get bound tokens.
  4. App-only API applications #124 MFA-gated scopes. Chosen: scripts.execute, terminal.execute and tenants.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.
  5. App-only API applications #124 secrets and keys. Chosen: server-generated 256-bit secrets, shown once, default 180 days, maximum 365, several live at once. Keys accepted: RSA ≥ 2048 and EC P-256/384/521; a duplicate is 409. Alternatives: client-chosen secrets; no maximum. Why: follows Microsoft Entra's guidance (< 12 months).
  6. Delegated API applications with consent #125 application kind is fixed. Chosen: app-only (role, client credentials) or delegated (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.
  7. 🔒 Delegated API applications with consent #125 PKCE off requires nonce. Chosen: requirePkce: false is accepted only with requireNonce: true, and every such request must be OIDC with a nonce, enforced server-side. All API applications are confidential. Alternative: a single replayProtection field. Why: RFC 9700 §2.1.1. The dashboard form (fix 16) offers only valid combinations.
  8. 🔒 Delegated API applications with consent #125 delegated scopes. Chosen: a delegated application can be granted any catalogue scope except 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.
  9. Delegated API applications with consent #125, fix 7: delegated access and Superadministrators. Chosen: a delegated token gets the person's assignments confined to the application's subtree and narrowed to its live scopes. Someone assigned at or above the application's tenant counts as assigned at it (an Administrator there, for a Superadministrator), but deployment.* still works. Alternatives: let them act at the application's own tenant; refuse deployment.* unless the application is registered at a root tenant.
  10. Delegated API applications with consent #125 consent. Chosen: every API application uses OpenIddict's explicit consent with no admin consent, and web is implicit. A single permanent authorization per person and application grows in place (API application refresh caps and connected apps #126). prompt=none answers consent_required. Only grants are audited. Alternatives: admin consent; a new authorization per consent. Why: one connection is one row.
  11. Delegated API applications with consent #125 consent screen and marker. Chosen: a server-rendered page with no script or style, a strict CSP, antiforgery and 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.
  12. Delegated API applications with consent #125 PAR. Chosen: PAR is at connect/par on 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.
  13. Delegated API applications with consent #125 refresh caps. Chosen: every client except web gets 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.
  14. Delegated API applications with consent #125 scope error. Chosen: a scope the client may not use is left as OpenIddict answers it (invalid_request, ID2051, on an error page). Alternative: a handler that rewrites it to RFC 6749's invalid_scope redirect.
  15. API application refresh caps and connected apps #126 connections API. Chosen: /tenants/{t}/api-applications/{a}/connections/{id} (the OpenIddict authorization ID), a subtree view, and a new /api/v1/me/connections pattern. 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. /me is new to the API.
  16. API application refresh caps and connected apps #126 connection authority. Chosen: apiapplications.read/write with 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: a connections.* noun; a bulk :revoke action.
  17. 🔒 Fix 15: Administrator applications and the lockout guards. Chosen: app-only applications holding the Administrator role don't count toward the last-Administrator guards (revocation and tenant deletion). Alternative: count them, which would also refuse deleting the last Administrator application. Why: the guard keeps a person able to manage the tree, and an application can't sign in or step up, and its credentials may belong to a vendor.
  18. 🔒 Fix 15: peer protection for Administrator applications. Chosen: deleting, re-roling or narrowing an Administrator application needs someone above its tenant (or a Superadministrator, or the application itself). Credential changes aren't peer-protected. So an Administrator at T can't undo an Administrator application they registered at T. Alternative: route credential removal through the peer rule. Why: rotation is routine upkeep.

Agents and devices

  1. Agent enrollment and mTLS authentication #128 enrollment takes a self-signed certificate, not a CSR. Chosen: the agent sends its own self-signed certificate (strictly validated: clientAuth, not a CA, P-256/384/521 or RSA ≥ 3072, ≤ 398 days). Alternatives: a CSR with public-key-only matching (deviates from RFC 8705 §2.2); a Signal9 agent CA (contradicts ADR-0004). Why: under self_signed_tls_client_auth the self-signature proves possession like a CSR. This deviates from the AC's wording.
  2. 🔒 Agent enrollment and mTLS authentication #128 one self_signed_tls_client_auth check. Chosen: one custom check in Signal9ApplicationManager for agents and API applications. The certificate must be byte-for-byte the first x5c of a registered JWK, its key must match the JWK, and its chain is built trusting only itself, on TimeProvider. OpenIddict's base check is never called. Alternative: add every registered certificate to OpenIddict's trust store. Why: stricter, with one EC path.
  3. 🔒 Agent enrollment and mTLS authentication #128 agent identity from the token. Chosen: a token counts as an agent's only with the agent scope, cnf, sub == client_id and 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.
  4. Agent enrollment and mTLS authentication #128 enrollment endpoint. Chosen: anonymous POST /api/v1/agents with the token in the body, 201 with Location. 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 OAuth invalid_grant.
  5. Agent enrollment and mTLS authentication #128 enrollment extras. Chosen: GET /tenants/{id}/agents is the "recently enrolled" view. EnrollmentTokenStatus.UsedUp is added (precedence Revoked > Expired > UsedUp > Active). The EnrollmentToken rate-limit policy is keyed by the secret's hash and acquired in the action. Alternative: a dedicated /recently-enrolled resource. Why: follows AGENTS.md's tenant-view convention.
  6. 🔒 Agent enrollment and mTLS authentication #128 agent key storage. Chosen: on Windows, a non-exportable CNG machine key (TPM, falling back to the software KSP). On other OSes enrollment fails rather than using an exportable key. Alternative: a software key on Linux/macOS. Why: the standards floor. The cost is no Linux or macOS enrollment yet, though multi-OS agents are a tenet (see the follow-ups).
  7. Agent enrollment and mTLS authentication #128 agent runtime config. Chosen: Agent:InstanceId scopes the key name and state folder, and Agent:TenantId is replaced by Agent:EnrollmentToken. The agent builds its own HttpClients per certificate instead of the factory's resilience handler. A refused agent keeps retrying with backoff until Spec: agent installers and lifecycle #76. Alternative: HttpClientFactory with resilience. Why: the certificate goes in the TLS handshake.
  8. Enrollment tokens #127 enrollment token design. Chosen: single use is 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 prefix s9e_ (invitations use s9i_). Alternatives: a singleUse flag; new token on rotation or an overlap window; DELETE. Why: retire leaked installers fast, and secret scanners can recognise the prefix.
  9. Agent revocation and key rotation #129 agent_revoked. Chosen: a revoked agent gets a 400 OAuth error agent_revoked from 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.
  10. Agent revocation and key rotation #129 revocation model. Chosen: POST /agents/{id}:revoke with devices.write (Technicians). The device's active agent is cleared (the device goes Offline), and the device gets RequiresAgentApproval for 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.
  11. 🔒 Agent revocation and key rotation #129 key rotation. Chosen: PUT /agents/{id}/certificate (agent only; GET with devices.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 :rotate action; let old tokens run 10 minutes. Why: a rotation after a suspected leak must take effect at once.
  12. Agent approval hold and agent status #130 approval. Chosen: AgentStatus (Active, Revoked, AwaitingApproval, plus Agent identification #131's NeedsDecision). POST /agents/{id}:approve needs devices.write. Held agents heartbeat (no work), see empty pending commands, get 409 on results, and may connect to the hub. Alternatives: an agents.approve scope; refuse the hub until approved. Why: ADR-0010 has five verbs, and the hub lets approval ring the agent promptly.
  13. Agent identification #131 an agent awaiting a decision has no device. Chosen: the agent has DeviceId = null and a PendingIdentification row, and the device is created or chosen only on decision (AgentDto.DeviceId is 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.
  14. Agent identification #131 identification API. Chosen: POST /agents/{id}:identify {decision: Reinstalled | KeepBoth, deviceId?} and GET /agents/{id}/candidate-devices. The queue is ?status=NeedsDecision. Reinstalled onto a device whose agent is active revokes that agent. Alternatives: :rebind and :keep-both verbs; a separate flag or resource. Why: one transition named after the glossary term.
  15. Agent identification #131 matching rules. Chosen: auto-match only on exactly one candidate with a revoked and no active agent. A match is SMBIOS UUID, or manufacturer and serial together. Junk identifiers are dropped server-side. Identifiers are indexed but not unique, and a match refreshes the device's facts. Alternatives: a serial alone; a unique constraint. Why: Keep both legitimately leaves duplicates.
  16. Fix: root-wide identification details. Chosen: candidates are shown per the caller's tenant filter (a West-only admin sees none in East), and Reinstalled accepts only visible candidates. A rebound agent belongs to the device's tenant even outside the token's subtree. Keep both creates the device in the token's tenant. Alternative: options A/B/C from the fix pass. Why: carries out the maintainer's decision without exposing devices the caller can't see.
  17. 🔒 Fix: fresh-OS claims are held for approval. Chosen: a fresh-OS single match joins as a held claim, always Awaiting approval. The old agent and the device's facts are untouched until a person approves at the device's tenant, which revokes the old agent. Revoking the claim leaves everything as it was. Alternative: rebind at once. Why: OsInstalledAt and the hardware IDs can be forged with a leaked installer.
  18. 🔒 Fix: fresh-OS signal and residual risk. Chosen: the install time is read from the registry InstallDate on Windows and statx birth 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 reset InstallDate (harmless, since claims are held). Alternative: no auto-resolve.
  19. Device transfer between tenants #123 approval state across transfers. Chosen: an active agent keeps its approval state when its device moves into a tenant that holds new agents. Alternative: put it back on hold. Why: the hold is for new agents (story 96), and the person moved the device deliberately.

Real-time

  1. 🔒 Dashboard hub scoped to the caller's subtree #132, Real-time hosting chosen by configuration #133 closing hub connections changed elsewhere. Chosen: api re-checks every open dashboard and agent hub connection every 10 s by default (token liveness, live access, groups). So sign-out, logout, Entra refusal, connection revocation and changes on another replica take effect within about 10 s, not at once. Alternatives: LISTEN/NOTIFY; Azure's CloseConnectionAsync; per-event checks. Why: auth and web share only the database, and the check also covers scale-out.
  2. Dashboard hub scoped to the caller's subtree #132 dashboard groups. Chosen: a connection joins only its top-most granting tenants, and Superadministrators join one deployment group. 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.
  3. Dashboard hub scoped to the caller's subtree #132 notice shape (public). Chosen: DeviceChanged/DeviceDeleted({deviceId, url}), CommandChanged({deviceId, commandId, url}) and TelemetryReceived({deviceId, sampleId, url}), with no tenant. Device transfers send DeviceChanged to both sides. Alternatives: one generic Changed({kind, url}); DeviceDeleted to the former side. Why: small notices, with details fetched through the BFF (ADR-0009).
  4. 🔒 Dashboard hub scoped to the caller's subtree #132 Origin check on web. Chosen: WebSocket handshakes to /hubs/dashboard* must carry web's exact origin, and any hub request that carries Origin must match. Requests without one pass only if they aren't handshakes. Mismatches get 403. Alternative: require Origin on every hub request. Why: the cookie risk exists only at the BFF, and requiring it would break same-origin long polling and SSE.
  5. Real-time hosting chosen by configuration #133 stateless negotiation for self-hosted scale-out. Chosen: with more than one replica, api answers negotiate itself with a stateless WebSockets-only response and drops the id, so clients stay unaware of the hosting mode. Alternatives: clients set SkipNegotiation (they'd need to know the mode); sticky sessions. Why: no client or contract change. The cost is emulating SignalR's negotiate response.
  6. Real-time hosting chosen by configuration #133 web proxies the service for the dashboard. Chosen: web rewrites api's Azure SignalR redirect to {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.
  7. 🔒 Real-time hosting chosen by configuration #133 service tokens. Chosen: service tokens carry 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.
  8. Real-time hosting chosen by configuration #133 hosting configuration. Chosen: Realtime:Hosting is AzureSignalR (publish default) or SelfHosted (run default). Realtime:ApiReplicas > 1 adds 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

  1. Hardening baseline for the hosts #105 rate limiting. Chosen: the token policy is keyed on client_id and IP together. A Global per-IP policy can cover every request, including OpenIddict's own endpoints (revocation and introspection get only Global), but it applies no limit until PermitLimit is configured; only the three defaults in item 141 are on out of the box. Fixed window, with no queueing and 429 plus Retry-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.
  2. Scope catalogue and scope-annotated endpoints #110 [RequireScope]. Chosen: a small custom attribute on IAuthorizationRequirementData. Several scopes in one attribute mean any one, several attributes must all hold. A 403 carries RFC 6750 insufficient_scope. Alternatives: Microsoft.Identity.Web [RequiredScope] (reads Entra's scp); one policy per scope (can't say "or"). Why: the dashboard hub needs any-of, and the RBAC filter reuses the requirement.
  3. Scope catalogue and scope-annotated endpoints #110 scope mapping. Chosen: everything an agent does about itself needs agent. GET /agents/{id} needs devices.read (there's no agents family), cancelling a command needs commands.execute, and RunScript also needs terminal.execute. Alternative: an agents scope family. Why: agents are part of a device's record.
  4. Scope catalogue and scope-annotated endpoints #110 OpenAPI and catalogue. Chosen: the OpenAPI document is public and anonymous in every environment. The scope catalogue is registered with OpenIddict, so only catalogue scopes are issued, and it lives in Server, not Contracts. Alternatives: Development only; catalogue in Contracts. Why: story 132 makes the document public. The catalogue's location limits the dashboard (item 128).
  5. Public API reference with Scalar #135 docs hosting. Chosen: web hosts Scalar at /docs and proxies only /docs/openapi/** to api. The document moves to /docs/openapi/v1.json with 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.
  6. Public API reference with Scalar #135 docs CSP and "Try it". Chosen: /docs adds a per-request nonce to script-src and style-src-elem only. "Try it" sends web's CSRF header through a same-origin customFetch, 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

  1. 🔒 Audit log #115 bypass audit policy (coordinator). Chosen: every TenantScopeBypass value declares [Audited] or [NotAudited(reason)], enforced by a test. Audited bypasses write a deployment-level tenant-scope.bypassed entry 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.
  2. 🔒 New not-audited bypasses. Chosen: all of these are [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.
  3. Audit log #115 audit log shape. Chosen: top-level 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.
  4. 🔒 Audit log #115, Tenant transfers #122, Device transfer between tenants #123, API application refresh caps and connected apps #126, Agent identification #131 what is recorded and where. Chosen: only 403 denials (filter 404s would reveal probes); Superadministrator writes but not reads; repeated failures counted per IP in memory per instance. Transfers record at both sides, not the moving tenant. Identification records its outcome, never identifiers. Alternatives: audit reads; a shared database count. Why: no flooding and no leaks.

Dashboard

  1. Fix 16 delegated-application scopes. Chosen: the register dialog offers delegated applications only the OIDC scopes and scopes some role grants, so 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.
  2. New dashboard pages. Chosen: role assignments, users, invitations (link shown once, copy by 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.
  3. Enrollment tokens #127, Align tenant updates and the agent executor with AGENTS.md conventions #144 small UI choices. Chosen: token expiry is entered as "Expires after (days)", not a date picker. Tenant status uses per-row Suspend, Activate and Cancel buttons, and Cancel has no confirmation because it's reversible. Alternative: a date picker; confirm Cancel. Why: KISS and testable.

Core-principles review

  1. Agent version refusal: 400 with problem+json (minimumVersion/maximumVersion), and OAuth unsupported_agent_version at the token endpoint. Alternatives: 426 (for switching protocols, needs Upgrade); 403 (reserved for insufficient_scope); 409. Why: RFC 9110 §15.5.1; Azure answers an unsupported api-version the same way.
  2. Agent version problem type URI: the repo's docs/agent-protocol.md#unsupported-agent-version on GitHub. Alternatives: a tag: URI; a future docs domain. Why: RFC 9457 wants a resolvable type, and it's one constant in AgentProtocol.
  3. Default supported agent range. Chosen: api's major.minor.0 up to api's own version, inclusive, configurable with AgentVersions: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.
  4. A refused agent idles. Chosen: the agent logs the refusal and its version range, stops talking to the API, and keeps the process running. Alternative: exit. Why: exiting lets SCM recovery or systemd Restart= retry in a loop; Spec: agent installers and lifecycle #76's updater builds on this state.
  5. 🔒 Fresh sign-in is server-side and single-use. Chosen: Entra sign-in writes a FreshSignIns row; 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.
  6. 🔒 OIDC prompt and max_age. Chosen: prompt=none never redirects (login_required); prompt=login re-authenticates at Entra, proved by a random attempt ID carried through Entra, and refused (access_denied, audited NotFresh) if Entra's auth_time predates the request by over 1 minute. Alternatives: forward prompt=none to Entra; a clock tolerance on max_age. Why: OIDC Core §3.1.2.1/§3.1.2.6; fixes max_age=0.
  7. 🔒 Token encryption algorithm (decision needed). Stays JOSE RSA-OAEP (SHA-1) + A256CBC-HS512: IdentityModel 8.19.2 (used by OpenIddict 7.7.2) has no RSA-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 custom ICryptoProvider with OAEP-SHA-256 (custom crypto to own); (c) symmetric A256KW shared by Signal9's hosts (new key management).
  8. 🔒 One self_signed_tls_client_auth certificate 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.
  9. web's 401 challenge. Chosen: 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.
  10. Preconditions on creates. Chosen: a create's preconditions are evaluated against the collection, so If-None-Match: * is 412 and If-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".
  11. Rate-limit defaults. Chosen: Token 300/min per (client_id, IP), Enrollment 60/min per IP, InvitationRedemption 30/min per IP, fixed windows. Alternatives: lower values (web refreshes every person's tokens as one client); Token per IP alone (one bucket for the dashboard and a NAT'd site's agents). Gap: rotating client_id from one address gets fresh buckets until Global is configured.
  12. Hubs: one registry and one sweeper. A revoked agent's connections are found by scanning the replica's open connections; one sweeper re-checks both hubs; config key Hubs:AccessCheckInterval. Alternatives: an agent-keyed index; one sweeper per hub.
  13. A span per background job run. Chosen: PeriodicJob starts an Activity (source OSI.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.
  14. Entra session check survives resilience timeouts. EntraRefresh.ForceAsync treats 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.
  15. The fixture's Superadministrator is a real person session, so it shows in sign-in audit entries and session checks; global counts now filter by person. Alternative: an app-only API application (can't create roots or cross trees).
  16. Removed scaffold fields: RestartMachine/RestartService, CommandPriority, SubscriptionPlan/Tenant.Plan, Device.MaintenanceMode, IsMutualTls; the GLOSSARY's "Maintenance mode" stays. RunScript stays: it carries terminal.execute and its step-up, covered by Spec: auth, tenancy and agent identity target #63's tests. Alternatives: keep Plan with Basic; drop the glossary entry; remove RunScript until Spec: command reliability, scripts and signing #82.
  17. Migrations squashed. Chosen: one generated 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

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

- 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>
@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Test suites

Suite Tests Passed Failed Skipped Other Duration
OSI.Signal9.Web.Tests 96 96 0 0 0 10.2s
OSI.Signal9.IntegrationTests 783 783 0 0 0 17m 36s
OSI.Signal9.Agent.Tests 64 64 0 0 0 492ms

Total: 943 tests — 943 passed, 0 failed, 0 skipped.

Github Test Reporter by CTRF 💚

🔄 This comment has been updated

MWG-Logan and others added 14 commits October 22, 2026 21:47
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>
Comment thread OSI.Signal9.API/Commands/CommandsController.cs Fixed
MWG-Logan and others added 9 commits October 23, 2026 00:31
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>
Comment thread OSI.Signal9.API/Commands/CommandsController.cs Fixed
MWG-Logan and others added 3 commits October 25, 2026 00:41
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>
MWG-Logan and others added 17 commits October 26, 2026 06:14
…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
MWG-Logan marked this pull request as ready for review October 26, 2026 14:55
MWG-Logan and others added 12 commits October 26, 2026 13:29
…#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants