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
@@ -1,9 +1,12 @@
// Tenant-scoped audit slice for the customer-admin dashboard. Proxies
// GET /tenants/:slug/audit with the signed-in user's access token. The
// platform-api enforces tenant membership and filters strictly by tenantSlug.
// Tenant-scoped audit slice for the customer-admin Security & audit page.
// Proxies GET /tenants/:slug/audit with the signed-in user's access token and
// forwards the filter/pagination params. platform-api enforces tenant
// membership and pins the query to this tenant's slug.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
const PASS_THROUGH = ['limit', 'q', 'action', 'outcome', 'actorEmail', 'since', 'before'] as const
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
@@ -11,10 +14,15 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const { limit } = getQuery(event)
const incoming = getQuery(event)
const query: Record<string, string> = {}
for (const k of PASS_THROUGH) {
const v = incoming[k]
if (v != null && v !== '') query[k] = String(v)
}
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/audit`, {
headers: { Authorization: `Bearer ${accessToken}` },
query: limit ? { limit } : undefined,
query,
})
})
@@ -0,0 +1,18 @@
// Live MFA-enrollment overview for the workspace. Proxies GET
// /tenants/:slug/mfa-status; platform-api enforces tenant membership and reads
// each member's enrollment from Authentik.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/mfa-status`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
})
@@ -0,0 +1,20 @@
// Save the workspace security policy (stored intent). Proxies PATCH
// /tenants/:slug/security-policy; platform-api enforces tenant membership.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const body = await readBody(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/security-policy`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${accessToken}` },
body,
})
})
@@ -0,0 +1,20 @@
// Remove an SSO app. Proxies DELETE /tenants/:slug/sso-apps/:id; platform-api
// deletes the Authentik application + provider and enforces tenant membership.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const id = getRouterParam(event, 'id')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
await $fetch(`${base}/tenants/${slug}/sso-apps/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
})
return { ok: true }
})
@@ -0,0 +1,17 @@
// List the tenant's SSO apps. Proxies GET /tenants/:slug/sso-apps;
// platform-api enforces tenant membership.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/sso-apps`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
})
@@ -0,0 +1,21 @@
// Register a new SSO app (Dezky as IdP). Proxies POST /tenants/:slug/sso-apps
// with { name, redirectUris }; platform-api creates the Authentik provider +
// application and returns the client credentials (secret shown once).
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const body = await readBody(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/sso-apps`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body,
})
})
@@ -0,0 +1,19 @@
// Aggregate storage usage for the customer-admin Storage page. Proxies
// GET /tenants/:slug/storage with the signed-in user's access token;
// platform-api enforces tenant membership and computes the summary live from
// OCIS libregraph.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/storage`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
})