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