-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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.
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.
Beyond interactive users, machine callers authenticate with a per-user API key carried as Authorization: ApiKey {key}. Keys are:
- Generated via
IApiKeyServiceand 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.
Harpyx runs two independent role models side by side:
-
UserRoleis a single, global field on theUserrow — it describes what the account can do at the platform level, regardless of tenant. -
TenantRolelives on theUserTenantmembership 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).
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.
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.
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
TenantRoleis 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) |
— |
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.
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 < actorRolethrows). - 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).
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 —
TenantScopeServicepopulatesAccessibleTenantIdsfrom the full tenant list rather than fromUserTenant. - Can activate "all tenants" mode by setting the
harpyx_tenant_scope=allcookie (viaSetAllTenantsAsync); non-admins getfalseback and fall through to per-tenant scoping. - Passes every
CanManage*/CanUseChatcheck even without aUserTenantrow 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.
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.
Security-relevant events are persisted as AuditEvent rows:
loginaccess_deniedupload-
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.
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.