-
Notifications
You must be signed in to change notification settings - Fork 0
Authentication and Authorization
UncannyPrompt separates who you are (authentication) from what you can do (authorization). Identity is delegated to external IdPs or stored as an API key; authorization is enforced locally against a two-role model persisted in SQL Server.
- Authentication — handled at the WebApp edge by cookie, API key, and OAuth/OIDC schemes.
-
Authorization — enforced by
TenantScopeProvider/TenantScopeContext,PermissionService, andAccessControlQueries.
| Scheme | Purpose |
|---|---|
| Cookie | Browser sessions for Razor Pages and cookie-backed API calls |
| API key | Programmatic callers using X-API-Key or Authorization: Bearer ...
|
| Google OAuth | Optional external sign-in provider |
| Microsoft Entra ID | Optional OIDC sign-in provider through Microsoft.Identity.Web
|
| GitHub OAuth | Optional external sign-in provider |
External providers are registered conditionally by AddExternalProviderIfConfigured in src/UncannyPrompt.WebApp/Program.cs; a provider is enabled only when its required configuration values are present.
On first successful external login, the provider ticket is passed to IUserService.ProvisionExternalUserAsync.
That flow:
- Reads the provider subject id and email/name claims.
- Creates or finds the internal
User. - Links the external identity through
AuthIdentity. - Creates the user's personal
Tenant/Workspace/Projectscaffolding on first sign-in, with the user asTenantOwnerandCanGrant = trueon their own personal tenant. - Rewrites the session principal so
ClaimTypes.NameIdentifieris the internalUser.Id.
This keeps external provider ids out of the rest of the application. Application services and authorization handlers always work with the internal user id.
The browser session uses a hardened cookie named __Host-UncannyPrompt. Mutating cookie-authenticated JSON calls are protected by CookieAntiforgeryFilter, which expects the antiforgery token header:
X-CSRF-TOKEN
Razor Pages require authentication by default, with explicit anonymous access for login and public-prompt pages.
API key authentication is implemented by ApiKeyAuthenticationHandler. Keys are represented by UserApiKey rows and verified using IApiKeyHasher; plaintext is never stored.
X-API-Key: <key>or:
Authorization: Bearer <key>API keys inherit the owning user's UserRole / TenantRole surface — they don't unlock capabilities the underlying user doesn't already have.
Users manage keys from Developers -> Keys. The detailed endpoint reference is available after sign-in at /Developer/Api/Docs; the public overview is available anonymously at /Help/Api. See API for the developer-facing contract.
UncannyPrompt 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 theTenantMembershiprow — it describes what the account can do inside one specific tenant.
The two are orthogonal: a user can be Standard globally but TenantOwner in one tenant, or Admin globally with no TenantMembership 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, settings, audit). Bypasses tenant ACLs. |
Standard |
Default role. No platform-wide privileges; access is governed entirely by TenantRole and explicit ShareGrant rows. |
New users default to Standard. The AdminOnly policy (see AppConstants.AdminOnlyPolicy) restricts admin-only surfaces; it evaluates through AdminRequirementHandler, which resolves the current user from ClaimTypes.NameIdentifier and succeeds iff Status == Active && 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, folders, and prompts |
| 4 | Reviewer |
Use chat and review content, no write access to structure |
| 5 | Viewer |
Read-only; default for new memberships |
New TenantMembership rows are created as Viewer with CanGrant = false. The entity also tracks GrantedByUserId / GrantedAt so the chain of delegation is auditable and cascadable.
TenantScopeContext 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 TenantMembership 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 owner of a
TenantKind.Personaltenant cannot be moved offTenantOwneror removed. -
Cascading revocation — revoking a member also revokes the memberships that member granted, transitively, via the
GrantedByUserIdchain.
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 —
TenantScopeProviderpopulatesAccessibleTenantIdsfrom the full tenant list rather than fromTenantMembership. - Can activate "all tenants" mode by setting the
uncannyprompt_tenant_scope=allcookie (viaSetAllTenantsAsync); non-admins getfalseback and fall through to per-tenant scoping. - Passes every
CanManage*/CanUseChatcheck even without aTenantMembershiprow 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.
Note: the admin bypass is general-administrative. It does not grant decryption of other users' encrypted user-scoped secrets — those remain encrypted under per-user keys regardless of UserRole.
Orthogonal to tenant roles, ShareGrant represents explicit access to a target:
| Target type | Meaning |
|---|---|
| Project | Access to a project and its contained resources |
| Folder | Access to a folder subtree and contained prompts |
| Prompt | Access to a specific prompt |
Grant permissions are modeled by SharePermission (View, Edit, Manage) and mapped to application-level permissions by PermissionService.
Collection reads must not fetch everything and filter in memory. AccessControlQueries exposes composable IQueryable helpers so prompt, folder, sharing, and listing services apply access checks in SQL:
Caller -> Application service -> AccessControlQueries -> EF query -> SQL Server
Bootstrap platform admins are provisioned from SeedOptions:AdminUsers on WebApp startup (WebApplicationSeedExtensions), 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 via IAuditService — the only entry point, so the audit trail is uniform and append-only. Public-link resolution is audited as public_link.access.
Public links bypass interactive authentication but not validation. PublicShareLink stores:
-
TokenLookupHash— deterministic lookup hash used for indexed lookup. -
TokenHash— verification hash used after the indexed match. -
ExpiresAt,RevokedAt,IsDeleted— lifecycle controls.
The cookie and the external OIDC/OAuth session are cleared on sign-out. API keys are revoked from the Developers area through IUserApiKeyService and immediately invalidate all subsequent requests bearing the key.
- src/UncannyPrompt.WebApp/Program.cs
- src/UncannyPrompt.WebApp/Security/ApiKeyAuthenticationHandler.cs
- src/UncannyPrompt.Application/Abstractions/IUserApiKeyService.cs
- src/UncannyPrompt.WebApp/Security/CookieAntiforgeryFilter.cs
-
src/UncannyPrompt.WebApp/Security/AdminRequirement.cs /
AdminRequirementHandler.cs -
src/UncannyPrompt.WebApp/Security/TenantScopeContext.cs /
TenantScopeProvider.cs - src/UncannyPrompt.Application/Services/TenantMembershipService.cs
- src/UncannyPrompt.Application/Services/PermissionService.cs
- src/UncannyPrompt.Application/Services/AccessControlQueries.cs
-
src/UncannyPrompt.Domain/Enums/UserRole.cs /
TenantRole.cs