feat(portal): real Security & audit page (+ bundled Storage / per-tenant-roles WIP)

Security & audit (admin)
- Audit log: real, tenant-scoped — widened GET /tenants/:slug/audit with
  q/action/outcome/actorEmail/since/before; UI gains search, outcome + time
  filters, action chips, cursor pagination, and client-side CSV export.
- Security policy: new tenant.securityPolicy (mfaMode, session idle/absolute,
  allowedCountries, ipAllowlist) + PATCH /tenants/:slug/security-policy
  (membership-gated, audited). Editable, labelled by enforcement status.
- MFA: live enrollment overview via GET /tenants/:slug/mfa-status
  (Authentik countAuthenticators per member).
- SSO apps (Dezky as IdP): real Authentik OIDC provider + application CRUD,
  scoped to the tenant group. New AuthentikClient methods (provider/app/binding
  + flow/key/scope discovery), TenantSsoApp schema, TenantSsoService (rollback
  on partial failure; client secret never stored), GET/POST/DELETE
  /tenants/:slug/sso-apps. Validated end-to-end against live Authentik.
- Deferred: shared-flow MFA/geo/session enforcement (global auth-flow blast
  radius) — to be done as its own reviewed change.

Bundled in-progress work that shares the same files (kept together so the tree
stays green):
- Storage page: StorageService + GET /tenants/:slug/storage (OCIS-backed),
  storage.get proxy, storage.vue.
- Per-tenant roles: User.tenantRoles + MeProfile.tenantRoles plumbing.
This commit is contained in:
Ronni Baslund
2026-05-31 17:20:36 +02:00
parent 3288fde693
commit 559348f6bc
27 changed files with 1744 additions and 148 deletions
@@ -21,6 +21,12 @@ export class AuthentikClient {
this.token = config.getOrThrow<string>('AUTHENTIK_API_TOKEN')
}
// Public Authentik origin (no /api/v3) — for building user-facing OIDC URLs
// like the per-app issuer / .well-known discovery document.
get publicBase(): string {
return this.base.replace(/\/api\/v3\/?$/, '')
}
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${this.base}${path}`, {
...init,
@@ -313,6 +319,126 @@ export class AuthentikClient {
})
this.logger.log(`Set brand ${brandUuid} flow_recovery → ${flowUuid}`)
}
// ── SSO apps: customer registers external apps using Dezky as the IdP ─────
// We create an OAuth2/OIDC Provider + Application in Authentik and bind the
// tenant's group to the application so only that workspace's members can use
// it. Provider pk is a number; Application pk is a uuid (slug is the human id).
// Find a flow pk by designation, preferring a slug substring (e.g. the
// explicit-consent authorization flow) and falling back to the first match.
async findFlowPk(designation: string, preferSlugIncludes?: string): Promise<string | undefined> {
const res = await this.request<{ results: AuthentikFlow[] }>(
`/flows/instances/?designation=${encodeURIComponent(designation)}`,
)
if (!res.results.length) return undefined
if (preferSlugIncludes) {
const pref = res.results.find((f) => f.slug.includes(preferSlugIncludes))
if (pref) return pref.pk
}
return res.results[0].pk
}
async findSigningKeyPk(): Promise<string | undefined> {
const res = await this.request<{ results: Array<{ pk: string; private_key_available?: boolean }> }>(
`/crypto/certificatekeypairs/?has_key=true`,
)
return res.results.find((k) => k.private_key_available)?.pk ?? res.results[0]?.pk
}
// The three standard OIDC scope mappings, resolved by Authentik's stable
// `managed` identifiers (pks differ per instance).
async findOidcScopeMappingPks(): Promise<string[]> {
const res = await this.request<{ results: Array<{ pk: string; managed?: string }> }>(
`/propertymappings/provider/scope/`,
)
const wanted = new Set([
'goauthentik.io/providers/oauth2/scope-openid',
'goauthentik.io/providers/oauth2/scope-email',
'goauthentik.io/providers/oauth2/scope-profile',
])
return res.results.filter((m) => m.managed && wanted.has(m.managed)).map((m) => m.pk)
}
async createOAuth2Provider(input: {
name: string
redirectUris: string[]
clientType?: 'confidential' | 'public'
}): Promise<{ pk: number; clientId: string; clientSecret: string }> {
const [authorizationFlow, invalidationFlow, signingKey, scopeMappings] = await Promise.all([
this.findFlowPk('authorization', 'explicit-consent'),
this.findFlowPk('invalidation', 'provider-invalidation'),
this.findSigningKeyPk(),
this.findOidcScopeMappingPks(),
])
if (!authorizationFlow) throw new Error('No Authentik authorization flow available')
const body: Record<string, unknown> = {
name: input.name,
authorization_flow: authorizationFlow,
client_type: input.clientType ?? 'confidential',
redirect_uris: input.redirectUris.map((url) => ({ matching_mode: 'strict', url })),
property_mappings: scopeMappings,
sub_mode: 'hashed_user_id',
}
if (invalidationFlow) body.invalidation_flow = invalidationFlow
if (signingKey) body.signing_key = signingKey
const p = await this.request<{ pk: number; client_id: string; client_secret: string }>(
'/providers/oauth2/',
{ method: 'POST', body: JSON.stringify(body) },
)
this.logger.log(`Created Authentik OAuth2 provider "${input.name}" (pk=${p.pk})`)
return { pk: p.pk, clientId: p.client_id, clientSecret: p.client_secret }
}
async createApplication(input: {
name: string
slug: string
providerPk: number
group?: string
}): Promise<{ pk: string; slug: string }> {
const app = await this.request<{ pk: string; slug: string }>('/core/applications/', {
method: 'POST',
body: JSON.stringify({
name: input.name,
slug: input.slug,
provider: input.providerPk,
group: input.group ?? '',
}),
})
this.logger.log(`Created Authentik application "${input.slug}" (pk=${app.pk})`)
return { pk: app.pk, slug: app.slug }
}
// A binding with a group and no policy = allow that group. Scopes the app to
// the tenant's workspace members.
async bindGroupToApplication(appPk: string, groupPk: string): Promise<void> {
await this.request('/policies/bindings/', {
method: 'POST',
body: JSON.stringify({ target: appPk, group: groupPk, order: 0, enabled: true }),
})
}
async deleteApplication(slug: string): Promise<void> {
const res = await fetch(`${this.base}/core/applications/${slug}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${this.token}` },
})
if (!res.ok && res.status !== 404) {
const body = await res.text().catch(() => '')
throw new Error(`Authentik DELETE application ${slug}${res.status}: ${body.slice(0, 200)}`)
}
}
async deleteOAuth2Provider(pk: number): Promise<void> {
const res = await fetch(`${this.base}/providers/oauth2/${pk}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${this.token}` },
})
if (!res.ok && res.status !== 404) {
const body = await res.text().catch(() => '')
throw new Error(`Authentik DELETE provider ${pk}${res.status}: ${body.slice(0, 200)}`)
}
}
}
export interface AuthentikFlow {
@@ -1,20 +1,151 @@
import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
// OCIS provisioning is stubbed for now. Real implementation needs:
// 1. Service-to-service auth via OIDC client_credentials (or admin user)
// 2. Call the libregraph /graph/v1.0/drives endpoint to create a project space
// 3. Assign the space to the tenant's group / users
// Phase 4 ships the orchestration; OCIS hooks up in a follow-up.
// A libregraph quota object as returned on each drive. All byte counts are
// integers; `total` of 0 means "unlimited" in OCIS (the default in dev).
export interface OcisQuota {
total?: number
used?: number
remaining?: number
deleted?: number
state?: string
}
// A libregraph drive. We only model the fields the storage summary needs.
export interface OcisDrive {
id: string
name?: string
driveType?: string // 'personal' | 'project' | 'virtual' | 'mountpoint'
quota?: OcisQuota
owner?: { user?: { id?: string; displayName?: string } }
}
// A libregraph user. `mail` is the join key back to our Mongo User docs.
export interface OcisUser {
id: string
displayName?: string
mail?: string
}
// OCIS integration. Provisioning (ensureSpace/deleteSpace) is still stubbed —
// it needs the project-space create call (see docs/NEXT-STEPS.md). The READ
// layer below is real: it lists per-drive quota for the customer-admin Storage
// page via the libregraph /graph/v1.0 API.
//
// Auth: OCIS has no built-in service-account/client-credentials grant for
// backend access (ownCloud devs: "one needs to go through OIDC authentication
// to obtain an access token"), and it trusts exactly one issuer. So we run an
// OIDC Resource-Owner-Password grant against the SAME Authentik provider OCIS
// trusts (client `ocis-web`), as a dedicated service user that holds the OCIS
// admin role (required to list all drives). The short-lived token is cached and
// refreshed in memory. Basic auth (PROXY_ENABLE_BASIC_AUTH) doesn't resolve the
// IDM admin in our external-IdP setup, hence this route.
@Injectable()
export class OcisClient {
private readonly logger = new Logger(OcisClient.name)
private readonly base: string
private readonly tokenUrl?: string
private readonly clientId?: string
private readonly clientSecret?: string
private readonly username?: string
private readonly password?: string
private readonly scope: string
// In-memory token cache. Tokens live minutes; we re-grant when within the
// skew window. Never persisted — same lifecycle as the process.
private token?: string
private tokenExpiresAt = 0
constructor(config: ConfigService) {
this.base = config.getOrThrow<string>('OCIS_API_URL')
this.tokenUrl = config.get<string>('OCIS_OIDC_TOKEN_URL') || undefined
this.clientId = config.get<string>('OCIS_OIDC_CLIENT_ID') || undefined
this.clientSecret = config.get<string>('OCIS_OIDC_CLIENT_SECRET') || undefined
this.username = config.get<string>('OCIS_SVC_USERNAME') || undefined
this.password = config.get<string>('OCIS_SVC_PASSWORD') || undefined
this.scope = config.get<string>('OCIS_OIDC_SCOPE') || 'openid profile email'
}
// True once we have everything needed to mint a token. When false the read
// methods short-circuit so the Storage page renders an "unavailable" state
// instead of erroring.
get configured(): boolean {
return !!(this.tokenUrl && this.clientId && this.username && this.password)
}
// ROPC (password) grant against the ocis provider's token endpoint. Cached
// until ~30s before expiry. The resulting token's issuer matches
// OCIS_OIDC_ISSUER and its preferred_username maps to the service user.
private async getToken(): Promise<string> {
if (this.token && Date.now() < this.tokenExpiresAt) return this.token
const body = new URLSearchParams({
grant_type: 'password',
client_id: this.clientId!,
username: this.username!,
password: this.password!,
scope: this.scope,
})
// Public clients (ocis-web) omit the secret; included only if configured
// (e.g. a confidential service provider).
if (this.clientSecret) body.set('client_secret', this.clientSecret)
const res = await fetch(this.tokenUrl!, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body,
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`OCIS token grant → ${res.status}: ${text.slice(0, 200)}`)
}
const json = (await res.json()) as { access_token?: string; expires_in?: number }
if (!json.access_token) throw new Error('OCIS token grant returned no access_token')
this.token = json.access_token
this.tokenExpiresAt = Date.now() + Math.max(0, (json.expires_in ?? 300) - 30) * 1000
return this.token
}
private async request<T>(path: string): Promise<T> {
const token = await this.getToken()
const res = await fetch(`${this.base}${path}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
})
if (!res.ok) {
// A 401 likely means a stale cached token; drop it so the next call
// re-grants. (One retry is enough; callers degrade gracefully on error.)
if (res.status === 401) {
this.token = undefined
this.tokenExpiresAt = 0
}
const body = await res.text().catch(() => '')
throw new Error(`OCIS GET ${path}${res.status}: ${body.slice(0, 200)}`)
}
return (await res.json()) as T
}
// List all drives, optionally filtered with an OData $filter expression
// (e.g. `driveType eq 'personal'`). Requires the OCIS admin role. libregraph
// caps the page at 100 items; a tenant's personal drives stay well under that.
async listDrives(filter?: string): Promise<OcisDrive[]> {
if (!this.configured) return []
const qs = filter ? `?$filter=${encodeURIComponent(filter)}` : ''
const body = await this.request<{ value?: OcisDrive[] }>(`/graph/v1.0/drives${qs}`)
return body.value ?? []
}
// List all OCIS users so we can map a drive's owner id back to an email and
// join against our tenant membership.
async listUsers(): Promise<OcisUser[]> {
if (!this.configured) return []
const body = await this.request<{ value?: OcisUser[] }>('/graph/v1.0/users')
return body.value ?? []
}
// ── Provisioning (stubbed) ────────────────────────────────────────────────
// Real implementation needs POST /graph/v1.0/drives { name, driveType:
// 'project' } to create a space and assign it to the tenant's group / users.
// Phase 4 ships the orchestration; this hooks up in a follow-up.
async ensureSpace(slug: string): Promise<{ id: string }> {
this.logger.warn(`OCIS space provisioning is stubbed — would create space for "${slug}" at ${this.base}`)
return { id: `stub-${slug}` }
@@ -0,0 +1,40 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type TenantSsoAppDocument = HydratedDocument<TenantSsoApp>
// An external application the tenant has registered to use Dezky (Authentik) as
// its identity provider. We mirror the Authentik objects here (provider +
// application refs) so the portal can list/manage without re-querying Authentik;
// the client_secret is never stored — it's shown once at creation time.
@Schema({ collection: 'tenant_sso_apps', timestamps: true })
export class TenantSsoApp {
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
tenantId!: Types.ObjectId
@Prop({ required: true, trim: true })
name!: string
// 'oidc' for now (SAML support can join later without a schema change).
@Prop({ enum: ['oidc', 'saml'], default: 'oidc' })
protocol!: 'oidc' | 'saml'
// OIDC client id (safe to display). Secret is intentionally not persisted.
@Prop()
clientId?: string
@Prop({ type: [String], default: [] })
redirectUris!: string[]
// Authentik handles — used for delete/reconcile.
@Prop()
authentikAppSlug?: string
@Prop()
authentikAppPk?: string // application uuid
@Prop({ type: Number })
authentikProviderPk?: number
}
export const TenantSsoAppSchema = SchemaFactory.createForClass(TenantSsoApp)
@@ -78,6 +78,29 @@ export class Tenant {
contactEmail?: string
}
// Customer-managed security policy. Stored intent; enforcement is wired
// incrementally (MFA enrollment is read live; MFA/geo/session enforcement
// via Authentik lands in a later stage). mfaMode: 'all' | 'admins' |
// 'optional'. Timeouts in minutes/hours. allowedCountries = ISO alpha-2;
// ipAllowlist = CIDR strings.
@Prop({
type: {
mfaMode: { type: String, enum: ['all', 'admins', 'optional'], default: 'optional' },
sessionIdleMinutes: { type: Number, min: 0 },
sessionAbsoluteHours: { type: Number, min: 0 },
allowedCountries: { type: [String], default: undefined },
ipAllowlist: { type: [String], default: undefined },
},
default: () => ({ mfaMode: 'optional' }),
})
securityPolicy!: {
mfaMode: 'all' | 'admins' | 'optional'
sessionIdleMinutes?: number
sessionAbsoluteHours?: number
allowedCountries?: string[]
ipAllowlist?: string[]
}
// Per-integration provisioning state. Each one is updated independently when its
// upstream API call succeeds or fails — orchestration is best-effort, not atomic.
@Prop({
@@ -5,7 +5,11 @@ export type UserDocument = HydratedDocument<User>
export type UserRole = 'owner' | 'admin' | 'member'
@Schema({ collection: 'users', timestamps: true })
// toObject must flatten Maps too: meWithPartner() returns user.toObject() for
// partner-staff, and JSON.stringify on a raw Map yields {} — which would drop
// tenantRoles from the /me payload. toJSON already flattens by default; this
// makes toObject() agree so the field survives in every serialization path.
@Schema({ collection: 'users', timestamps: true, toObject: { flattenMaps: true } })
export class User {
// Authentik subject claim — stable identity across login sessions.
@Prop({ required: true, unique: true, index: true })
@@ -30,10 +34,24 @@ export class User {
@Prop({ required: true, trim: true })
name!: string
// Role is per-user globally for the MVP. Refine to per-tenant later if needed.
// Legacy global role + the fallback when a tenant has no explicit entry in
// tenantRoles below. For partner staff this is their partner-org role (used
// by the partner last-admin guard). For tenant users it's superseded by the
// per-tenant entry. Always resolve a tenant-scoped role via roleForTenant(),
// never by reading this field directly.
@Prop({ enum: ['owner', 'admin', 'member'], default: 'member' })
role!: UserRole
// Per-tenant role overrides, keyed by stringified tenant ObjectId. A present
// key wins for that tenant; absence falls back to the global `role` above
// (so existing single-role users keep their role everywhere with no
// migration). This lets one user be 'admin' of tenant A and 'member' of
// tenant B — the flat global role couldn't express that, and re-inviting an
// existing user into a second tenant used to silently keep their first
// tenant's role. Resolve via roleForTenant().
@Prop({ type: Map, of: String, default: undefined })
tenantRoles?: Map<string, UserRole>
@Prop({ default: true })
active!: boolean
@@ -59,3 +77,19 @@ export class User {
}
export const UserSchema = SchemaFactory.createForClass(User)
// Single source of truth for tenant-scoped role resolution. A per-tenant entry
// in tenantRoles wins; absent, fall back to the legacy global `role`, then
// 'member'. Backend and frontend (apps/portal useMe.roleForTenant) must keep
// this precedence in sync. Accepts both a hydrated Map and a plain object (e.g.
// a lean() result or a JSON-deserialized doc).
export function roleForTenant(
user: Pick<User, 'role' | 'tenantRoles'>,
tenantId: Types.ObjectId | string,
): UserRole {
const key = String(tenantId)
const roles = user.tenantRoles
const fromMap =
roles instanceof Map ? roles.get(key) : (roles as Record<string, UserRole> | undefined)?.[key]
return fromMap ?? user.role ?? 'member'
}
@@ -0,0 +1,14 @@
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, IsUrl, MaxLength, MinLength } from 'class-validator'
// Register an external OIDC app that uses Dezky as its identity provider.
export class CreateSsoAppDto {
@IsString() @MinLength(1) @MaxLength(80)
name!: string
// One or more exact redirect URIs the app will use after sign-in.
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(20)
@IsUrl({ require_tld: false, require_protocol: true }, { each: true })
redirectUris!: string[]
}
@@ -0,0 +1,33 @@
import {
ArrayMaxSize,
IsArray,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator'
// Customer-editable security policy. Stored intent; enforcement is wired
// incrementally. Narrow — never touches plan/status/partner.
export class UpdateSecurityPolicyDto {
@IsOptional() @IsEnum(['all', 'admins', 'optional'])
mfaMode?: 'all' | 'admins' | 'optional'
@IsOptional() @IsInt() @Min(0) @Max(1440)
sessionIdleMinutes?: number
@IsOptional() @IsInt() @Min(0) @Max(8760)
sessionAbsoluteHours?: number
// ISO alpha-2 country codes (uppercase). Empty array clears the allow-list.
@IsOptional() @IsArray() @ArrayMaxSize(250) @IsString({ each: true }) @MaxLength(2, { each: true })
allowedCountries?: string[]
// CIDR / IP strings. Lightly validated as strings; format-checked at the
// enforcement layer when geo/IP wiring lands.
@IsOptional() @IsArray() @ArrayMaxSize(200) @IsString({ each: true }) @MaxLength(64, { each: true })
ipAllowlist?: string[]
}
@@ -0,0 +1,142 @@
import { Injectable, Logger } from '@nestjs/common'
import { OcisClient } from '../integrations/ocis.client.js'
import { TenantsService } from './tenants.service.js'
const GIB = 1024 ** 3
// Human-facing plan labels, mirroring composables/useTenant.ts so the portal
// shows the same names everywhere.
const PLAN_LABEL: Record<string, string> = {
mvp: 'Starter',
pro: 'Business',
enterprise: 'Enterprise',
}
// Fallback "allocated" per plan, used only when OCIS reports unlimited drives
// (quota.total === 0, the dev default). Once per-plan storage lands as real
// config (Price schema or tenant override), this map goes away.
const PLAN_QUOTA_BYTES: Record<string, number> = {
mvp: 100 * GIB,
pro: 1024 * GIB,
enterprise: 5 * 1024 * GIB,
}
export interface StorageTopUser {
name: string
email: string
usedBytes: number
}
export interface StorageSummary {
// False when OCIS is unreachable / basic auth isn't configured — the page
// renders an "unavailable" state rather than zeros that look like real data.
available: boolean
plan: string
usedBytes: number
quotaBytes: number
freeBytes: number
trashBytes: number
driveCount: number
topUsers: StorageTopUser[]
}
// Computes a tenant's aggregate storage live by joining its Dezky users to
// their OCIS personal drives. Read-only, no persistence — every call hits
// libregraph. Mapping key is email (OCIS users are auto-provisioned from
// Authentik's preferred_username and carry the same mail).
@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name)
constructor(
private readonly tenants: TenantsService,
private readonly ocis: OcisClient,
) {}
async getStorageSummary(slug: string): Promise<StorageSummary> {
const tenant = await this.tenants.findOneBySlug(slug)
const planKey = tenant.plan ?? 'mvp'
const planLabel = PLAN_LABEL[planKey] ?? planKey
const empty: StorageSummary = {
available: false,
plan: planLabel,
usedBytes: 0,
quotaBytes: PLAN_QUOTA_BYTES[planKey] ?? 0,
freeBytes: 0,
trashBytes: 0,
driveCount: 0,
topUsers: [],
}
if (!this.ocis.configured) return empty
const members = await this.tenants.listUsersForTenant(slug)
const memberByEmail = new Map(
members.map((m) => [m.email.toLowerCase(), m]),
)
let ocisUsers
let personalDrives
try {
;[ocisUsers, personalDrives] = await Promise.all([
this.ocis.listUsers(),
this.ocis.listDrives("driveType eq 'personal'"),
])
} catch (err) {
// OCIS down, wrong credentials, basic auth disabled — degrade gracefully.
this.logger.warn(`Storage summary for "${slug}" falling back to unavailable: ${String(err)}`)
return empty
}
// OCIS user id → email, so we can attribute each drive's owner to a member.
const emailByOcisId = new Map(
ocisUsers
.filter((u) => u.mail)
.map((u) => [u.id, u.mail!.toLowerCase()]),
)
let usedBytes = 0
let trashBytes = 0
let quotaFromDrives = 0
let driveCount = 0
const usedByEmail = new Map<string, number>()
for (const drive of personalDrives) {
const ownerId = drive.owner?.user?.id
const email = ownerId ? emailByOcisId.get(ownerId) : undefined
if (!email || !memberByEmail.has(email)) continue // not a member of this tenant
const used = drive.quota?.used ?? 0
usedBytes += used
trashBytes += drive.quota?.deleted ?? 0
quotaFromDrives += drive.quota?.total ?? 0
driveCount += 1
usedByEmail.set(email, (usedByEmail.get(email) ?? 0) + used)
}
// Prefer real OCIS-reported allocation; fall back to the plan map when
// drives are unlimited (total 0).
const quotaBytes = quotaFromDrives > 0 ? quotaFromDrives : (PLAN_QUOTA_BYTES[planKey] ?? 0)
const topUsers: StorageTopUser[] = [...usedByEmail.entries()]
.map(([email, used]) => ({
email,
name: memberByEmail.get(email)?.name ?? email,
usedBytes: used,
}))
.sort((a, b) => b.usedBytes - a.usedBytes)
.slice(0, 5)
return {
available: true,
plan: planLabel,
usedBytes,
quotaBytes,
freeBytes: Math.max(0, quotaBytes - usedBytes),
trashBytes,
driveCount,
topUsers,
}
}
}
@@ -0,0 +1,163 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { AuthentikClient } from '../integrations/authentik.client.js'
import { TenantSsoApp, type TenantSsoAppDocument } from '../schemas/tenant-sso-app.schema.js'
import { Tenant, type TenantDocument } from '../schemas/tenant.schema.js'
import type { CreateSsoAppDto } from './dto/create-sso-app.dto.js'
export interface SsoAppView {
id: string
name: string
protocol: 'oidc' | 'saml'
clientId?: string
redirectUris: string[]
issuer?: string
wellKnownUrl?: string
createdAt?: string
}
// Customer-managed SSO apps (Dezky as IdP). Creates an Authentik OAuth2 provider
// + application bound to the tenant's group; mirrors the refs in Mongo so the
// portal lists/deletes without re-querying Authentik.
@Injectable()
export class TenantSsoService {
private readonly logger = new Logger(TenantSsoService.name)
constructor(
@InjectModel(TenantSsoApp.name) private readonly model: Model<TenantSsoAppDocument>,
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
private readonly authentik: AuthentikClient,
private readonly audit: AuditService,
) {}
private toView(d: TenantSsoAppDocument): SsoAppView {
const issuer = d.authentikAppSlug
? `${this.authentik.publicBase}/application/o/${d.authentikAppSlug}/`
: undefined
return {
id: String(d._id),
name: d.name,
protocol: d.protocol,
clientId: d.clientId,
redirectUris: d.redirectUris ?? [],
issuer,
wellKnownUrl: issuer ? `${issuer}.well-known/openid-configuration` : undefined,
createdAt: (d as TenantSsoAppDocument & { createdAt?: Date }).createdAt?.toISOString(),
}
}
async list(tenant: TenantDocument): Promise<SsoAppView[]> {
const docs = await this.model.find({ tenantId: tenant._id }).sort({ createdAt: -1 }).exec()
return docs.map((d) => this.toView(d))
}
// Ensure the tenant has an Authentik group to scope apps to; provisions one
// on demand for tenants created before group provisioning existed.
private async ensureGroupPk(tenant: TenantDocument): Promise<string> {
if (tenant.authentikGroupId) return tenant.authentikGroupId
const group = await this.authentik.ensureGroup(tenant.slug)
tenant.authentikGroupId = group.pk
await tenant.save()
return group.pk
}
private kebab(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'app'
}
private async uniqueSlug(base: string): Promise<string> {
let slug = base
let n = 1
while (await this.model.exists({ authentikAppSlug: slug })) {
n += 1
slug = `${base}-${n}`
}
return slug
}
async create(
tenant: TenantDocument,
dto: CreateSsoAppDto,
actor?: AuditActor,
): Promise<SsoAppView & { clientSecret: string }> {
const groupPk = await this.ensureGroupPk(tenant)
const slug = await this.uniqueSlug(`dezky-${tenant.slug}-${this.kebab(dto.name)}`)
// Create provider → application → group binding. On any failure, roll back
// whatever was created so we don't leave orphaned Authentik objects.
let providerPk: number | undefined
let appCreated = false
try {
const provider = await this.authentik.createOAuth2Provider({
name: `${tenant.name} · ${dto.name}`,
redirectUris: dto.redirectUris,
})
providerPk = provider.pk
const app = await this.authentik.createApplication({
name: dto.name,
slug,
providerPk: provider.pk,
group: tenant.slug,
})
appCreated = true
await this.authentik.bindGroupToApplication(app.pk, groupPk)
const doc = await this.model.create({
tenantId: tenant._id,
name: dto.name,
protocol: 'oidc',
clientId: provider.clientId,
redirectUris: dto.redirectUris,
authentikAppSlug: slug,
authentikAppPk: app.pk,
authentikProviderPk: provider.pk,
})
void this.audit.record(
{
action: 'tenant.sso_app_created',
resourceType: 'tenant',
resourceId: String(tenant._id),
resourceName: tenant.name,
tenantSlug: tenant.slug,
metadata: { app: dto.name, slug },
},
actor,
)
// client_secret is returned once, never persisted.
return { ...this.toView(doc), clientSecret: provider.clientSecret }
} catch (err) {
if (appCreated) await this.authentik.deleteApplication(slug).catch(() => {})
if (providerPk != null) await this.authentik.deleteOAuth2Provider(providerPk).catch(() => {})
throw err
}
}
async remove(tenant: TenantDocument, id: string, actor?: AuditActor): Promise<void> {
const doc = await this.model.findOne({ _id: id, tenantId: tenant._id }).exec()
if (!doc) throw new NotFoundException('SSO app not found')
if (doc.authentikAppSlug) await this.authentik.deleteApplication(doc.authentikAppSlug).catch((e) => {
this.logger.warn(`SSO delete: app ${doc.authentikAppSlug}${e instanceof Error ? e.message : e}`)
})
if (doc.authentikProviderPk != null) await this.authentik.deleteOAuth2Provider(doc.authentikProviderPk).catch((e) => {
this.logger.warn(`SSO delete: provider ${doc.authentikProviderPk}${e instanceof Error ? e.message : e}`)
})
await doc.deleteOne()
void this.audit.record(
{
action: 'tenant.sso_app_deleted',
resourceType: 'tenant',
resourceId: String(tenant._id),
resourceName: tenant.name,
tenantSlug: tenant.slug,
metadata: { app: doc.name },
},
actor,
)
}
}
@@ -20,11 +20,15 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import { OperatorGuard } from '../auth/operator.guard.js'
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { CreateSsoAppDto } from './dto/create-sso-app.dto.js'
import { CreateTenantDto } from './dto/create-tenant.dto.js'
import { UpdateBillingInfoDto } from './dto/update-billing-info.dto.js'
import { UpdateSecurityPolicyDto } from './dto/update-security-policy.dto.js'
import { UpdateTenantBrandingDto } from './dto/update-tenant-branding.dto.js'
import { UpdateTenantDto } from './dto/update-tenant.dto.js'
import { StorageService } from './storage.service.js'
import { TenantBrandingService } from './tenant-branding.service.js'
import { TenantSsoService } from './tenant-sso.service.js'
import { TenantsService } from './tenants.service.js'
// Build the audit actor from a resolved User doc + the originating request.
@@ -48,6 +52,8 @@ export class TenantsController {
private readonly actor: ActorService,
private readonly audit: AuditService,
private readonly branding: TenantBrandingService,
private readonly storage: StorageService,
private readonly sso: TenantSsoService,
) {}
@Post()
@@ -86,6 +92,20 @@ export class TenantsController {
return this.tenants.listUsersForTenant(slug)
}
// Aggregate storage usage for the customer-admin Storage page. Same membership
// gate as GET :slug/users — any member of the tenant can read their own
// workspace's storage. Computed live from OCIS libregraph (read-only); if OCIS
// is unreachable the summary comes back with available=false.
@Get(':slug/storage')
async getStorage(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return this.storage.getStorageSummary(slug)
}
// Tenant-scoped audit slice for the customer-admin dashboard. Same membership
// gate as GET :slug — any member of the tenant can read their own workspace's
// activity. This is the portal-accessible counterpart to the operator-only
@@ -96,6 +116,12 @@ export class TenantsController {
@Param('slug') slug: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Query('limit') limit?: string,
@Query('q') q?: string,
@Query('action') action?: string,
@Query('outcome') outcome?: string,
@Query('actorEmail') actorEmail?: string,
@Query('since') since?: string,
@Query('before') before?: string,
) {
const actor = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
@@ -103,9 +129,17 @@ export class TenantsController {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
const parsed = limit ? Number.parseInt(limit, 10) : undefined
// Always pinned to this tenant's slug — the filters only ever narrow within
// it, never widen across tenants.
return this.audit.list({
tenantSlug: slug,
limit: Number.isFinite(parsed) ? parsed : undefined,
q: q || undefined,
action: action || undefined,
outcome: outcome === 'success' || outcome === 'failure' ? outcome : undefined,
actorEmail: actorEmail || undefined,
since: since ? new Date(since) : undefined,
before: before ? new Date(before) : undefined,
})
}
@@ -169,6 +203,76 @@ export class TenantsController {
return this.branding.put(tenant, dto, auditActor(user, req))
}
// Security policy (stored intent; enforcement wired incrementally).
@Patch(':slug/security-policy')
async updateSecurityPolicy(
@Param('slug') slug: string,
@Body() dto: UpdateSecurityPolicyDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const user = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return this.tenants.updateSecurityPolicy(slug, dto, auditActor(user, req))
}
// Live MFA-enrollment overview for the workspace.
@Get(':slug/mfa-status')
async mfaStatus(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const user = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return this.tenants.mfaStatusForTenant(slug)
}
// SSO apps — Dezky as IdP. Creates real Authentik OAuth2 providers +
// applications scoped to the tenant group. Membership-gated.
@Get(':slug/sso-apps')
async listSsoApps(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const user = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return this.sso.list(tenant)
}
@Post(':slug/sso-apps')
async createSsoApp(
@Param('slug') slug: string,
@Body() dto: CreateSsoAppDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const user = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return this.sso.create(tenant, dto, auditActor(user, req))
}
@Delete(':slug/sso-apps/:id')
@HttpCode(204)
async deleteSsoApp(
@Param('slug') slug: string,
@Param('id') id: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const user = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
await this.sso.remove(tenant, id, auditActor(user, req))
}
@Delete(':slug')
@HttpCode(204)
async remove(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters<typeof clientIp>[0]) {
@@ -6,10 +6,13 @@ import { IntegrationsModule } from '../integrations/integrations.module.js'
import { PricesModule } from '../prices/prices.module.js'
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
import { TenantBranding, TenantBrandingSchema } from '../schemas/tenant-branding.schema.js'
import { TenantSsoApp, TenantSsoAppSchema } from '../schemas/tenant-sso-app.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { ProvisioningService } from './provisioning.service.js'
import { StorageService } from './storage.service.js'
import { TenantBrandingService } from './tenant-branding.service.js'
import { TenantSsoService } from './tenant-sso.service.js'
import { TenantsController } from './tenants.controller.js'
import { TenantsService } from './tenants.service.js'
@@ -23,6 +26,7 @@ import { TenantsService } from './tenants.service.js'
// lookup goes through PricesService for the soft-active filter.
{ name: Subscription.name, schema: SubscriptionSchema },
{ name: TenantBranding.name, schema: TenantBrandingSchema },
{ name: TenantSsoApp.name, schema: TenantSsoAppSchema },
]),
AuthModule,
AuditModule,
@@ -30,7 +34,7 @@ import { TenantsService } from './tenants.service.js'
PricesModule,
],
controllers: [TenantsController],
providers: [TenantsService, ProvisioningService, TenantBrandingService],
providers: [TenantsService, ProvisioningService, TenantBrandingService, StorageService, TenantSsoService],
exports: [TenantsService],
})
export class TenantsModule {}
@@ -8,6 +8,7 @@ import {
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { AuthentikClient } from '../integrations/authentik.client.js'
import { StripeClient } from '../integrations/stripe.client.js'
import { PricesService } from '../prices/prices.service.js'
import type { PriceCurrency, PriceCycle, PriceDocument } from '../schemas/price.schema.js'
@@ -17,6 +18,7 @@ import { User, UserDocument } from '../schemas/user.schema.js'
import type { PartnerUpdateTenantDto } from '../me/dto/partner-update-tenant.dto.js'
import type { CreateTenantDto } from './dto/create-tenant.dto.js'
import type { UpdateBillingInfoDto } from './dto/update-billing-info.dto.js'
import type { UpdateSecurityPolicyDto } from './dto/update-security-policy.dto.js'
import type { UpdateTenantDto } from './dto/update-tenant.dto.js'
import { ProvisioningService } from './provisioning.service.js'
@@ -32,6 +34,7 @@ export class TenantsService {
private readonly audit: AuditService,
private readonly prices: PricesService,
private readonly stripe: StripeClient,
private readonly authentik: AuthentikClient,
) {}
async listUsersForTenant(slug: string): Promise<UserDocument[]> {
@@ -278,6 +281,76 @@ export class TenantsService {
return tenant
}
// Narrow update of the security policy (stored intent). Dotted $set so each
// provided field changes independently; arrays replace wholesale.
async updateSecurityPolicy(
slug: string,
dto: UpdateSecurityPolicyDto,
actor?: AuditActor,
): Promise<TenantDocument> {
const set: Record<string, unknown> = {}
if (dto.mfaMode !== undefined) set['securityPolicy.mfaMode'] = dto.mfaMode
if (dto.sessionIdleMinutes !== undefined) set['securityPolicy.sessionIdleMinutes'] = dto.sessionIdleMinutes
if (dto.sessionAbsoluteHours !== undefined) set['securityPolicy.sessionAbsoluteHours'] = dto.sessionAbsoluteHours
if (dto.allowedCountries !== undefined) {
set['securityPolicy.allowedCountries'] = dto.allowedCountries.map((c) => c.toUpperCase())
}
if (dto.ipAllowlist !== undefined) set['securityPolicy.ipAllowlist'] = dto.ipAllowlist
const tenant = await this.tenantModel
.findOneAndUpdate({ slug }, { $set: set }, { new: true, runValidators: true })
.exec()
if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
void this.audit.record(
{
action: 'tenant.security_policy_updated',
resourceType: 'tenant',
resourceId: String(tenant._id),
resourceName: tenant.name,
tenantSlug: tenant.slug,
metadata: { fields: Object.keys(set) },
},
actor,
)
return tenant
}
// Live MFA-enrollment overview for the workspace. Reads each member's
// Authentik authenticator count (TOTP/WebAuthn/static); a count > 0 = enrolled.
// Best-effort per user — a failed Authentik lookup counts as not-enrolled
// rather than failing the whole call.
async mfaStatusForTenant(slug: string): Promise<{
total: number
enrolled: number
members: Array<{ id: string; name: string; email: string; role: string; enrolled: boolean }>
}> {
const tenant = await this.findOneBySlug(slug)
const users = await this.userModel
.find({ tenantIds: tenant._id, active: { $ne: false } })
.sort({ name: 1 })
.exec()
const members = await Promise.all(
users.map(async (u) => {
let enrolled = false
if (u.authentikUserPk != null) {
try {
enrolled = (await this.authentik.countAuthenticators(u.authentikUserPk)) > 0
} catch {
enrolled = false
}
}
return { id: String(u._id), name: u.name, email: u.email, role: u.role, enrolled }
}),
)
return {
total: members.length,
enrolled: members.filter((m) => m.enrolled).length,
members,
}
}
async softDelete(slug: string, actor?: AuditActor): Promise<void> {
const result = await this.tenantModel
.findOneAndUpdate({ slug }, { status: 'deleted' }, { new: true })
@@ -59,12 +59,20 @@ export class UsersService {
if (exists) throw new ConflictException(`User ${dto.authentikSubjectId} already exists`)
const tenantIds = await this.resolveTenantIds(dto.tenantSlugs ?? [])
const role = dto.role ?? 'member'
// Seed the per-tenant role for every tenant this user is created into, so
// their effective role is explicit from the start rather than relying on
// the global-role fallback. Omitted when there are no tenants.
const tenantRoles = tenantIds.length
? Object.fromEntries(tenantIds.map((id) => [String(id), role]))
: undefined
return this.userModel.create({
authentikSubjectId: dto.authentikSubjectId,
email: dto.email,
name: dto.name,
role: dto.role ?? 'member',
role,
tenantIds,
tenantRoles,
})
}
@@ -645,7 +653,11 @@ export class UsersService {
.findOneAndUpdate(
{ authentikSubjectId: existing.uid },
{
$set: { email: existing.email },
// tenantRoles via $set (not $setOnInsert) so an EXISTING user
// invited as admin to this tenant actually becomes admin here,
// without disturbing their role in other tenants. The global
// `role` stays $setOnInsert as the legacy/first-tenant fallback.
$set: { email: existing.email, [`tenantRoles.${tenant._id}`]: 'admin' },
$setOnInsert: {
name: existing.name || dto.name,
role: 'admin',
@@ -688,7 +700,8 @@ export class UsersService {
.findOneAndUpdate(
{ authentikSubjectId: created.uid },
{
$set: { email: dto.email, name: dto.name },
// Per-tenant admin role via $set (see attach branch above).
$set: { email: dto.email, name: dto.name, [`tenantRoles.${tenant._id}`]: 'admin' },
$setOnInsert: { role: 'admin', active: true, platformAdmin: false },
$addToSet: { tenantIds: tenant._id },
},