Skip to content

Authentication and Authorization

Valerio edited this page Apr 21, 2026 · 2 revisions

Authentication and Authorization

Harpyx separates who you are (authentication) from what you can do (authorization). Identity is delegated to external IdPs; authorization is enforced locally against an allowlist and role model persisted in SQL Server.

Authentication

Microsoft Entra ID (primary)

OpenID Connect via Microsoft.Identity.Web. Registration happens in AuthenticationExtensions.AddHarpyxAuthentication, which binds the EntraId:* configuration section:

Key Description
EntraId:Instance OIDC authority base, typically https://login.microsoftonline.com/
EntraId:TenantId Azure AD tenant GUID
EntraId:ClientId App registration GUID
EntraId:ClientSecret App registration secret
EntraId:CallbackPath Sign-in callback (default /signin-oidc)
EntraId:Authority Derived from Instance + TenantId by HarpyxConfigurationComposer

The derived Authority is also read by Swagger to publish the OpenID Connect discovery URL on the API surface.

Google OAuth (secondary)

Registered through Microsoft.AspNetCore.Authentication.Google using Google:ClientId / Google:ClientSecret / Google:CallbackPath. Useful for external collaborators who don't live in the primary Entra tenant.

Cookie + CSRF

Once the external IdP completes, the session is materialized as a cookie. All mutating controller actions are protected by an AutoValidateAntiforgeryTokenAttribute filter registered globally in AddHarpyxWebAppServices, so CSRF tokens are required by default.

API key authentication

Beyond interactive users, machine callers authenticate with a per-user API key carried as Authorization: ApiKey {key}. Keys are:

  • Generated via IApiKeyService and stored as salted hashes (never plaintext).
  • Scoped by ApiPermission (see src/Harpyx.Domain/Enums/ApiPermission.cs).
  • Subject to the same allowlist and tenant role checks as browser sessions.

Authorization

Harpyx runs two independent role models side by side:

  • UserRole is a single, global field on the User row — it describes what the account can do at the platform level, regardless of tenant.
  • TenantRole lives on the UserTenant membership row — it describes what the account can do inside one specific tenant.

The two are orthogonal: a user can be ReadOnly globally but TenantOwner in one tenant, or Admin globally with no UserTenant row at all. Effective access is the union of the two checks, with platform Admin short-circuiting every tenant-level capability (see Admin bypass below).

Platform roles (UserRole)

Defined in UserRole:

Role Intent
Admin Full platform administration (tenants, users, plans, settings)
Operator Day-to-day operations
Reviewer Can inspect content/audit
ReadOnly Consumption-only access

The AdminOnly policy restricts /Tenants, /Users, and the entire Admin area; see the Razor conventions in AddHarpyxWebAppServices. Policy evaluation flows through AdminRequirementHandler, which resolves the current user and succeeds iff Role == UserRole.Admin.

Tenant roles (TenantRole)

Defined in TenantRole. Values are ordered numerically from most to least privileged — authorization checks compare ordinals (role <= TenantRole.WorkspaceManager), so adding a new role in the middle is a breaking change.

Ord Role Intent
0 TenantOwner Full control of the tenant, including ownership transfer
1 TenantManager Manage members, workspaces, projects; cannot remove the last owner
2 WorkspaceManager Manage workspaces and projects inside the tenant
3 ProjectContributor Create/edit projects and their documents
4 Reviewer Use chat and review content, no write access to structure
5 Viewer Read-only; default for new memberships

New UserTenant rows are created as Viewer with CanGrant = false (UserTenantRepository). The entity also tracks GrantedByUserId / GrantedAt so the chain of delegation is auditable.

Capability matrix

TenantScopeContext (source) is the single place where tenant-level capabilities are decided. Each capability requires the tenant to be in scope and one of:

  • the caller is a platform Admin (bypass), or
  • the caller's TenantRole is at or above the threshold below.
Capability Minimum TenantRole Extra requirement
CanManageMembers TenantManager (≤ 1) CanGrant == true
CanManageWorkspaces WorkspaceManager (≤ 2)
CanManageProjects ProjectContributor (≤ 3)
CanUseChat Reviewer (≤ 4)

The CanGrant flag

CanGrant on UserTenant is orthogonal to the role. It gates who can delegate permissions, so you can have a TenantManager without the authority to add new members. TenantMembershipService.CanGrantForRole auto-sets it to true only for TenantOwner and TenantManager assignments; other roles keep it false.

Membership invariants

Enforced by TenantMembershipService, non-negotiable for non-admin actors:

  • At least one TenantOwner — revoking or demoting the last owner throws. Applies to both admin and non-admin actors.
  • No privilege escalation — an actor cannot assign a role higher than their own (targetRole < actorRole throws).
  • No privilege inversion — an actor cannot modify or revoke a member whose role is stronger than theirs.
  • Personal tenants — the creator of a personal tenant cannot be moved off TenantOwner.
  • Cascading revocation — revoking a member also revokes the memberships that member granted, transitively.

Platform admins bypass the actor-side invariants (escalation / inversion / "can manage") but not the structural ones (last-owner, personal-tenant owner).

Admin bypass

TenantScopeContext short-circuits every capability with if (IsAdmin) return true before evaluating membership. In practice this means a platform Admin:

  • Sees all tenants as accessible — TenantScopeService populates AccessibleTenantIds from the full tenant list rather than from UserTenant.
  • Can activate "all tenants" mode by setting the harpyx_tenant_scope=all cookie (via SetAllTenantsAsync); non-admins get false back and fall through to per-tenant scoping.
  • Passes every CanManage* / CanUseChat check even without a UserTenant row for that tenant.

The admin bypass is the relationship between the two role systems — it's what lets a small number of global operators administer tenants they aren't members of, without having to insert synthetic memberships.

Allowlist

Authenticated users who are not on the AuthorizedUsers allowlist are denied with an audited access_denied event. Bootstrap platform admins are provisioned from SeedOptions:AdminUsers on WebApp startup, so the first sign-in is never locked out of itself.

Seeded admins are normal User rows with Role = Admin; after bootstrap, they flow through the same AdminOnly policy and AdminRequirementHandler as every other admin account. See Configuration for the Provider / UniqueId / Email format.

Audit

Security-relevant events are persisted as AuditEvent rows:

  • login
  • access_denied
  • upload
  • job_start, job_end, job_error, job_skipped_security

IAuditService.RecordAsync is the only entry point — all subsystems go through it so the audit trail is uniform and append-only.

Sign-out

The cookie and the OIDC session are both cleared on sign-out. API keys are revoked via the admin UI / IApiKeyService and immediately invalidate all subsequent requests bearing the key.

Source pointers

Clone this wiki locally