# Feature Specification: 063 — Entra Sign-in (Tenant Panel) v1 **Feature Branch**: `063-entra-signin` **Created**: 2026-01-26 **Status**: Draft (v1) **Scope**: Tenant Admin panel (`/admin`) sign-in only --- ## Context / Goal TenantAtlas needs a clean, enterprise-grade sign-in flow for the **Tenant Admin panel** (`/admin`) using **Microsoft Entra ID (OIDC)**. This feature MUST: - provide a Microsoft Entra sign-in entrypoint on `/admin/login` (Entra-only enforcement is finalized in Feature 064) - upsert tenant users safely keyed by `(entra_tenant_id, entra_object_id)` - route users after login based on **tenant memberships** - keep pages **DB-only at render time** - log safely (no tokens/claims dumps) This feature intentionally does **not** change platform/operator access (`/system`) or break-glass behavior; those belong to Feature **064-auth-structure**. --- ## Scope boundary (063 vs 064) 063 introduces Entra OIDC sign-in capability for the tenant panel: - Entra redirect + callback - Safe user upsert keyed by (tid, oid) - Membership-based post-login routing (0 / 1 / N memberships) Making "/admin" fully **Entra-only** (removing local form login and removing any break-glass/operator UX from /admin) is finalized in **Feature 064 (Auth Structure)**. --- ## Clarifications ### Session 2026-01-26 - Q: For a user belonging to multiple tenants, what should happen immediately after they sign in? → A: Redirect to a dedicated, full-screen "tenant chooser" page that lists all their memberships. The user must click one to proceed to the main dashboard. - Q: If a user record exists in a disabled state (e.g., `deleted_at` is not null, or an `is_active` flag is false), and that user completes a valid Entra ID sign-in, what should happen? → A: Block the login. Redirect the user to the login page with a generic error message (e.g., "Your account is disabled. Please contact an administrator."). - Q: What is the v1 DB strategy for users.entra_tenant_id / users.entra_object_id? → A: Keep existing columns/types if present (currently varchar(255) nullable) and keep the existing unique composite index. Do not enforce NOT NULL or type-change in v1. --- ### Session 2026-01-27 - Q: How should the Entra Tenant ID (`tid`) be treated in logs? → A: Log the `entra_tenant_id` in plaintext. Rationale: `tid` is a tenant identifier, not a user secret. Plaintext is valuable for operations and incident response. The primary PII (`oid`) is hashed. --- ## Existing System Compatibility (important) The repository already contains: - A **System Panel** at `/system` using guard `platform` and `platform_users` (platform operator access). - Break-glass recovery mechanics for platform operators (banner/middleware/routes). - Tenant membership storage using a pivot table (`tenant_user` or equivalent) with role values via `TenantRole` enum. **Compatibility constraints for 063** - 063 MUST NOT modify `/system` panel, platform guards, platform users, or break-glass routes/UX. - 063 MUST NOT refactor tenant RBAC data model or enforcement. It may only **read** memberships to decide where to redirect after login. - 063 MUST NOT introduce Graph calls (or any outbound HTTP) during render/poll/hydration of `/admin` pages. --- ## Non-Goals (explicit) - No Platform Operator `/system` login implementation (already exists or handled elsewhere). - No break-glass UX on `/admin/login`. - No tenant RBAC redesign or role enforcement changes (assume memberships already exist). - No Graph calls or remote work in login render/poll/hydration. - No storing of Entra access/refresh tokens. - No queue/worker dependence for login (login is synchronous request/response). --- ## User Scenarios & Testing (mandatory) ### User Story 1 — Entra sign-in entrypoint on `/admin/login` (Priority: P1) A tenant user can start Microsoft Entra sign-in from `/admin/login`. **Acceptance Scenarios** 1. Given I open `/admin/login`, then I see only “Sign in with Microsoft” and no email/password fields. 2. Given Entra config is missing/invalid, `/admin/login` still renders and shows a generic message (no secrets). **Independent Test** - Render `/admin/login` and assert no password inputs exist. --- ### User Story 2 — OIDC callback upserts tenant identity safely (Priority: P1) The callback upserts a tenant user using Entra claims. **Acceptance Scenarios** 1. Given Entra callback includes `tid` and `oid`, when sign-in completes, then `users` is upserted keyed by: - `entra_tenant_id = tid` - `entra_object_id = oid` 2. Given sign-in succeeds, then the session is regenerated. 3. Given callback is missing `tid` or `oid`, then redirect to `/admin/login` with a generic error. 4. Given a user exists but is disabled (soft-deleted), when they complete a valid sign-in, then they are redirected to /admin/login with a generic error. **Independent Test** - Fake a callback payload and assert `(tid, oid)` uniqueness is enforced. --- - Q: What is the specific title and primary message for the `/admin/no-access` page? → A: Title: "No Access", Message: "Please contact an administrator for access." ### User Story 3 — Post-login routing is membership-based (Priority: P1) After login, routing depends on Suite tenant memberships. **Acceptance Scenarios** 1. Given I have 0 memberships, then redirect to `/admin/no-access`. 2. Given I have exactly 1 membership, then redirect into that tenant’s dashboard. 3. Given I have >1 memberships, then redirect to a dedicated, full-screen tenant chooser page, displaying tenant name and role for each option. **Independent Test** - Seed memberships and assert each redirect path. --- ### User Story 4 — Filament-native “No access” page (Priority: P2) Users without memberships get a safe, Filament-native page. **Acceptance Scenarios** 1. Given I have 0 memberships, `/admin/no-access` renders using Filament UI (no raw HTML pages), with the title "No Access" and the message "Please contact an administrator for access.". 2. The page does not leak internal details; it provides next steps (“Ask an admin to add you”). --- ## Requirements (mandatory) ### Functional Requirements - **FR-001**: `/admin/login` MUST offer a "Sign in with Microsoft" entrypoint that starts the Entra OIDC flow. (Entra-only removal of local/break-glass UX is finalized in Feature 064.) - **FR-002**: Tenant user upsert MUST be keyed by `(entra_tenant_id, entra_object_id)` and MUST NOT store Entra access/refresh tokens. - **FR-003**: Post-login routing MUST be based on memberships: - 0 → `/admin/no-access` - 1 → tenant dashboard - N → dedicated tenant chooser page, displaying tenant name and role for each option. - **FR-004**: OIDC failures MUST be handled safely: - redirect to `/admin/login` with generic error - log stable `reason_code` + `correlation_id` - never log token/claims payloads - **FR-005**: Logging MUST be privacy-safe: - success: minimal (user_id, tid, oid hash, timestamp, correlation_id) - failure: `reason_code`, correlation_id, minimal diagnostics - **FR-006**: `/admin/login`, `/admin/no-access`, and `/admin/choose-tenant` MUST be DB-only at render/hydration time (no outbound HTTP). - **FR-007**: 063 MUST NOT surface break-glass links or platform login UX on `/admin/login`. - **FR-008**: Session separation MUST prevent implicit crossover: - a tenant session MUST NOT grant access to `/system` - a platform session MUST NOT grant access to `/admin` tenant membership routes (Implementation is via separate guards/panels; 063 only asserts behavior via tests.) - **FR-009**: Login flow MUST NOT require queue workers; it must complete synchronously. - **FR-010**: If a user record exists but is in a disabled or soft-deleted state, a successful Entra ID authentication MUST be blocked, and the user redirected to the login page with a generic error. ### Non-Functional Requirements (NFR) - **NFR-01 (Security)**: Do not persist secrets/tokens. Sanitize all error output and logs. - **NFR-02 (Stability)**: Callback is idempotent; safe to retry without creating duplicates. - **NFR-03 (Performance)**: Callback returns within ~2s under normal conditions. - **NFR-04 (Maintainability)**: Minimal diff; do not refactor membership/RBAC models. --- ## Auth handshake exception to Operations/Run standard The Operations/Run Observability standard applies to queued/long-running tenant operations (sync, restore, drift, provider ops). The Entra OIDC sign-in handshake is an interactive authentication flow that necessarily performs outbound HTTPS to Entra endpoints during the callback. This auth handshake is explicitly exempt from the OperationRun requirement: - It is user-initiated and must complete synchronously to establish a session. - It does not represent a tenant-scoped background operation. - It must not enqueue jobs or perform remote work beyond the OIDC exchange. Guardrail: Only the `/auth/entra/*` endpoints may perform outbound HTTP for OIDC. `/admin/login`, `/admin/no-access`, and `/admin/choose-tenant` remain DB-only at render/hydration time. --- ## Data Model 063 reuses the existing `users` table. **Current repo state (authoritative for v1):** - `entra_tenant_id` (varchar(255), nullable) - `entra_object_id` (varchar(255), nullable) - Unique composite index on (`entra_tenant_id`, `entra_object_id`) already exists. **v1 decision (enterprise-safe, minimal-risk):** - Keep both columns **nullable** in v1. - Do **not** change column types (no uuid migration) in v1. - Do **not** enforce NOT NULL in v1. Optional (if already present / used later): - `last_tenant_id` (nullable FK to `tenants.id`) to optimize redirect for multi-membership users. No new tables required. --- ## Routes / UI Surfaces - `/admin/login` — Filament login override (Entra-only CTA) - `/auth/entra/redirect` — starts OIDC redirect (Socialite) - `/auth/entra/callback` — handles callback, upsert, routing - `/admin/no-access` — Filament page for 0-membership users - `/admin/choose-tenant` — Filament page for N-membership users to select a tenant --- ## OIDC Handling & Failure Semantics ### Claims requirements - `tid` (tenant id) MUST be present. - `oid` (object id) MUST be present. If missing → fail safely. ### Generic user-facing error - “Authentication failed. Please try again.” ### Server-side logging Log event `auth.entra.login` with: - `success`: boolean - `reason_code`: string (on failure) - `user_id`: (on success) - `entra_tenant_id`: tid (plaintext, for Ops correlation) - `entra_object_id_hash`: hash(oid) - `correlation_id`: request id or session id - `timestamp` ### Reason code examples (stable) - `oidc_missing_claims` - `oidc_invalid_state` - `oidc_user_denied` - `oidc_provider_unavailable` - `oidc_user_upsert_failed` - `user_disabled` --- ## Implementation Guardrails (hard) - Do not implement a password login form for `/admin`. - Do not call `$this->form->fill()` with default creds. - Do not show break-glass link/button on `/admin/login`. - Do not modify `/system` panel, platform guards, or break-glass logic. - Do not refactor `User::tenants()` or membership schema; use a small resolver/service to decide redirect. - Do not make outbound HTTP during render/hydration of /admin/login, /admin/no-access, or /admin/choose-tenant. Outbound HTTP is permitted only inside /auth/entra/* endpoints for the OIDC exchange. - Do not store raw claims or tokens. --- ## Acceptance Tests (required) ### Feature tests (Pest) 1. **AdminLoginIsEntraOnlyTest** - GET `/admin/login` contains Microsoft CTA - asserts no `password` input / no local login form 2. **EntraCallbackUpsertByTidOidTest** - callback upserts user by `(tid, oid)` (unique) - session is regenerated 3. **PostLoginRoutingByMembershipTest** - 0 memberships → `/admin/no-access` - 1 membership → tenant dashboard - N memberships → chooser page 4. **OidcFailureRedirectsSafelyTest** - missing tid/oid → redirect `/admin/login` - logs contain `reason_code` + `correlation_id` - logs do not contain tokens/claims dumps 5. **SessionSeparationSmokeTest** - tenant session cannot access `/system` - platform session cannot access tenant membership routes without membership 6. **DisabledUserLoginIsBlockedTest** - Seed a disabled/soft-deleted user - Fake a successful OIDC callback for that user - Assert redirect to /admin/login - Assert log contains `reason_code: user_disabled` ### Quality gate - `./vendor/bin/sail bin pint --dirty` - `./vendor/bin/sail artisan test tests/Feature/Auth --stop-on-failure` --- ## Manual Verification Checklist 1. Open `/admin/login` - only Microsoft sign-in CTA visible 2. Complete Entra sign-in - user record exists with tid/oid 3. 0 memberships → `/admin/no-access` 4. 1 membership → tenant dashboard 5. >1 memberships → chooser page 6. Verify logs: - failures show reason_code + correlation_id - no tokens/claims in logs --- ## Out of Scope / Follow-ups - **064-auth-structure**: `/system` operator login hardening, break-glass UX, panel separation governance. - **062-tenant-rbac-v1**: role enforcement audit + resource-by-resource authorization hardening. - Advanced Entra topics (v2+): delegated flows, refresh token storage, certificate auth, conditional access UI.