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:
@@ -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}` }
|
||||
|
||||
Reference in New Issue
Block a user