13 KiB
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_atis not null, or anis_activeflag 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 theentra_tenant_idin plaintext. Rationale:tidis 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
/systemusing guardplatformandplatform_users(platform operator access). - Break-glass recovery mechanics for platform operators (banner/middleware/routes).
- Tenant membership storage using a pivot table (
tenant_useror equivalent) with role values viaTenantRoleenum.
Compatibility constraints for 063
- 063 MUST NOT modify
/systempanel, 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
/adminpages.
Non-Goals (explicit)
- No Platform Operator
/systemlogin 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
- Given I open
/admin/login, then I see only “Sign in with Microsoft” and no email/password fields. - Given Entra config is missing/invalid,
/admin/loginstill renders and shows a generic message (no secrets).
Independent Test
- Render
/admin/loginand 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
- Given Entra callback includes
tidandoid, when sign-in completes, thenusersis upserted keyed by:entra_tenant_id = tidentra_object_id = oid
- Given sign-in succeeds, then the session is regenerated.
- Given callback is missing
tidoroid, then redirect to/admin/loginwith a generic error. - 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-accesspage? → 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
- Given I have 0 memberships, then redirect to
/admin/no-access. - Given I have exactly 1 membership, then redirect into that tenant’s dashboard.
- 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
- Given I have 0 memberships,
/admin/no-accessrenders using Filament UI (no raw HTML pages), with the title "No Access" and the message "Please contact an administrator for access.". - The page does not leak internal details; it provides next steps (“Ask an admin to add you”).
Requirements (mandatory)
Functional Requirements
- FR-001:
/admin/loginMUST 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.
- 0 →
- FR-004: OIDC failures MUST be handled safely:
- redirect to
/admin/loginwith generic error - log stable
reason_code+correlation_id - never log token/claims payloads
- redirect to
- 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-tenantMUST 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
/admintenant membership routes (Implementation is via separate guards/panels; 063 only asserts behavior via tests.)
- a tenant session MUST NOT grant access to
- 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 totenants.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: booleanreason_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 idtimestamp
Reason code examples (stable)
oidc_missing_claimsoidc_invalid_stateoidc_user_deniedoidc_provider_unavailableoidc_user_upsert_faileduser_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
/systempanel, 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)
- AdminLoginIsEntraOnlyTest
- GET
/admin/logincontains Microsoft CTA - asserts no
passwordinput / no local login form
- GET
- EntraCallbackUpsertByTidOidTest
- callback upserts user by
(tid, oid)(unique) - session is regenerated
- callback upserts user by
- PostLoginRoutingByMembershipTest
- 0 memberships →
/admin/no-access - 1 membership → tenant dashboard
- N memberships → chooser page
- 0 memberships →
- OidcFailureRedirectsSafelyTest
- missing tid/oid → redirect
/admin/login - logs contain
reason_code+correlation_id - logs do not contain tokens/claims dumps
- missing tid/oid → redirect
- SessionSeparationSmokeTest
- tenant session cannot access
/system - platform session cannot access tenant membership routes without membership
- tenant session cannot access
- 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
- Open
/admin/login- only Microsoft sign-in CTA visible - Complete Entra sign-in - user record exists with tid/oid
- 0 memberships →
/admin/no-access - 1 membership → tenant dashboard
-
1 memberships → chooser page
- Verify logs:
- failures show reason_code + correlation_id
- no tokens/claims in logs
Out of Scope / Follow-ups
- 064-auth-structure:
/systemoperator 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.