TenantAtlas/specs/063-entra-signin/spec.md
2026-01-27 17:22:33 +01:00

245 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 tenants 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.