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 {