From 1bae2b9f9885386bb6560b22e46ed65d1095de2d Mon Sep 17 00:00:00 2001 From: tcouper Date: Thu, 16 Jul 2026 17:08:20 +0100 Subject: [PATCH 1/7] Enhances SSO integration by adding organization ID support and member management features. Updates configuration for SSO claims and CGE credentials. Implements tenant membership roles and updates login screen for organization ID input. Adds new API endpoints for managing members and roles. --- apps/editor/src/app/App.tsx | 13 + .../infrastructure/auth/tokenScopes.test.ts | 36 ++ .../src/infrastructure/auth/tokenScopes.ts | 71 ++++ .../infrastructure/caseApi/CaseApiClient.ts | 132 +++++++ .../editor/src/infrastructure/caseApi/http.ts | 2 + apps/editor/src/ui/auth/LoginScreen.tsx | 106 ++++-- apps/editor/src/ui/home/HomeScreen.tsx | 89 ++++- apps/editor/src/ui/home/MembersDialog.tsx | 332 ++++++++++++++++++ apps/opencase/docs/DEVELOPER.md | 133 ++++--- .../domain/user/__tests__/memberRoles.test.ts | 37 ++ apps/opencase/src/domain/user/memberRoles.ts | 54 +++ .../src/infrastructure/cge/CgeApiClient.ts | 120 +++++++ .../src/infrastructure/cge/CgeAuthClient.ts | 72 ++++ .../cge/FileCgeCredentialsStore.ts | 117 ++++++ .../cge/__tests__/CgeAuthClient.test.ts | 44 +++ .../__tests__/FileCgeCredentialsStore.test.ts | 35 ++ .../src/infrastructure/config/Config.ts | 22 ++ .../config/__tests__/Config.test.ts | 3 + .../keycloak/KeycloakAdminClient.ts | 204 ++++++++++- .../keycloak/KeycloakTenantProvisioner.ts | 17 +- .../CFPackagesManagementController.ts | 33 +- .../controllers/CgeManagementController.ts | 194 ++++++++++ .../MembersManagementController.ts | 234 ++++++++++++ .../CFPackagesManagementController.test.ts | 14 + .../interfaces/http/http-management/routes.ts | 103 +++++- .../controllers/TenantLookupController.ts | 20 +- .../__tests__/TenantLookupController.test.ts | 29 ++ .../http/http-public/public/routes.ts | 6 +- .../http/middleware/__tests__/scope.test.ts | 93 ++++- .../middleware/__tests__/tenantAccess.test.ts | 27 ++ .../src/interfaces/http/middleware/scope.ts | 113 +++--- .../http/middleware/tenantAccess.ts | 33 ++ apps/opencase/src/interfaces/http/server.ts | 2 + apps/opencase/src/wiring/container.ts | 28 +- docker-compose.yml | 5 + docs/AUTH0_SSO.md | 28 +- docs/env.example | 21 +- 37 files changed, 2410 insertions(+), 212 deletions(-) create mode 100644 apps/editor/src/infrastructure/auth/tokenScopes.test.ts create mode 100644 apps/editor/src/infrastructure/auth/tokenScopes.ts create mode 100644 apps/editor/src/ui/home/MembersDialog.tsx create mode 100644 apps/opencase/src/domain/user/__tests__/memberRoles.test.ts create mode 100644 apps/opencase/src/domain/user/memberRoles.ts create mode 100644 apps/opencase/src/infrastructure/cge/CgeApiClient.ts create mode 100644 apps/opencase/src/infrastructure/cge/CgeAuthClient.ts create mode 100644 apps/opencase/src/infrastructure/cge/FileCgeCredentialsStore.ts create mode 100644 apps/opencase/src/infrastructure/cge/__tests__/CgeAuthClient.test.ts create mode 100644 apps/opencase/src/infrastructure/cge/__tests__/FileCgeCredentialsStore.test.ts create mode 100644 apps/opencase/src/interfaces/http/http-management/controllers/CgeManagementController.ts create mode 100644 apps/opencase/src/interfaces/http/http-management/controllers/MembersManagementController.ts create mode 100644 apps/opencase/src/interfaces/http/middleware/__tests__/tenantAccess.test.ts create mode 100644 apps/opencase/src/interfaces/http/middleware/tenantAccess.ts diff --git a/apps/editor/src/app/App.tsx b/apps/editor/src/app/App.tsx index 33595a7..4fc33eb 100644 --- a/apps/editor/src/app/App.tsx +++ b/apps/editor/src/app/App.tsx @@ -175,6 +175,19 @@ function AppInner() { setRoute('login') }, [authStatus, route]) + // SSO: ensure default tenant membership when org_id claim matches (idempotent). + const ensureSelfAttempted = useRef(null) + useEffect(() => { + if (authStatus !== 'authenticated' || !tenantId) return + const key = tenantId + if (ensureSelfAttempted.current === key) return + ensureSelfAttempted.current = key + void api.ensureSelfMembership({ tenantId }).catch((err: unknown) => { + // Expected when org_id claim is absent (non-SSO / local users). + console.debug('[App] ensure-self skipped or failed:', err) + }) + }, [authStatus, tenantId, api]) + // Fetch the full definitions catalogue from the management endpoint once authenticated. useEffect(() => { if (authStatus !== 'authenticated' || !tenantId) return diff --git a/apps/editor/src/infrastructure/auth/tokenScopes.test.ts b/apps/editor/src/infrastructure/auth/tokenScopes.test.ts new file mode 100644 index 0000000..92920d2 --- /dev/null +++ b/apps/editor/src/infrastructure/auth/tokenScopes.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { tokenHasCaseOwner, decodeJwtPayload } from './tokenScopes' + +function makeToken (payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url') + const body = Buffer.from(JSON.stringify(payload)).toString('base64url') + return `${header}.${body}.sig` +} + +describe('tokenScopes', () => { + it('detects case.owner from scope claim', () => { + expect(tokenHasCaseOwner(makeToken({ scope: 'case.read case.owner' }))).toBe(true) + }) + + it('detects admin membership role as tenant admin', () => { + expect(tokenHasCaseOwner(makeToken({ + resource_access: { 'tenant-demo': { roles: ['admin'] } }, + }))).toBe(true) + }) + + it('detects case.owner from resource_access roles', () => { + expect(tokenHasCaseOwner(makeToken({ + resource_access: { 'tenant-demo': { roles: ['case.owner'] } }, + }))).toBe(true) + }) + + it('returns false for author-only tokens', () => { + expect(tokenHasCaseOwner(makeToken({ scope: 'case.read case.write author' }))).toBe(false) + }) + + it('detects case.admin (system admin) as tenant admin for UI', () => { + expect(tokenHasCaseOwner(makeToken({ + resource_access: { 'tenant-system': { roles: ['case.admin'] } }, + }))).toBe(true) + }) +}) diff --git a/apps/editor/src/infrastructure/auth/tokenScopes.ts b/apps/editor/src/infrastructure/auth/tokenScopes.ts new file mode 100644 index 0000000..eb27897 --- /dev/null +++ b/apps/editor/src/infrastructure/auth/tokenScopes.ts @@ -0,0 +1,71 @@ +/** + * Decode a JWT payload without verifying signature (UI gating only). + * Server always enforces scopes. + */ +export function decodeJwtPayload (accessToken: string | null | undefined): Record | null { + if (!accessToken) return null + const parts = accessToken.split('.') + if (parts.length < 2) return null + try { + const json = base64UrlDecode(parts[1]) + return JSON.parse(json) as Record + } catch { + return null + } +} + +function base64UrlDecode (input: string): string { + let base64 = input.replaceAll('-', '+').replaceAll('_', '/') + const pad = base64.length % 4 + if (pad === 2) base64 += '==' + else if (pad === 3) base64 += '=' + else if (pad === 1) base64 += '===' // invalid length; atob may still throw + return atob(base64) +} + +function collectScopes (payload: Record): Set { + const scopes = new Set() + const raw = payload.scope + if (typeof raw === 'string') { + for (const s of raw.split(' ').filter(Boolean)) scopes.add(s) + } else if (Array.isArray(raw)) { + for (const s of raw) if (typeof s === 'string') scopes.add(s) + } + + const realmAccess = payload.realm_access as { roles?: unknown } | undefined + if (Array.isArray(realmAccess?.roles)) { + for (const r of realmAccess.roles) if (typeof r === 'string') scopes.add(r) + } + + const resourceAccess = payload.resource_access + if (resourceAccess && typeof resourceAccess === 'object') { + for (const client of Object.values(resourceAccess as Record)) { + const roles = client?.roles + if (Array.isArray(roles)) { + for (const r of roles) if (typeof r === 'string') scopes.add(r) + } + } + } + + // Membership labels + case.* hierarchy (matches OpenCASE middleware) + if (scopes.has('admin') || scopes.has('case.owner')) { + scopes.add('case.owner') + scopes.add('case.write') + scopes.add('case.read') + } else if (scopes.has('author') || scopes.has('case.write')) { + scopes.add('case.write') + scopes.add('case.read') + } else if (scopes.has('viewer') || scopes.has('case.read')) { + scopes.add('case.read') + } + + return scopes +} + +/** True when the access token can manage tenant members/keys (owner, membership admin, or system case.admin). */ +export function tokenHasCaseOwner (accessToken: string | null | undefined): boolean { + const payload = decodeJwtPayload(accessToken) + if (!payload) return false + const scopes = collectScopes(payload) + return scopes.has('case.owner') || scopes.has('admin') || scopes.has('case.admin') +} diff --git a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts index f18a064..ef11b84 100644 --- a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts +++ b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts @@ -49,6 +49,32 @@ export class CaseApiClient { return {} } + async lookupTenantByOrgId(params: { orgId: string }): Promise<{ tenantId?: string } | null> { + const orgId = params.orgId.trim() + if (!orgId) return null + const res = (await this._http.get(`/public/tenant-lookup?orgId=${encodeURIComponent(orgId)}`)) as unknown + if (!res || typeof res !== 'object') return null + const any = res as { tenantId?: unknown } + if (typeof any.tenantId === 'string' && any.tenantId.trim()) return { tenantId: any.tenantId.trim() } + return {} + } + + /** + * SSO first-login: assign default author roles when org_id claim matches tenant. + * Idempotent. Caller should re-authenticate if status is `assigned`. + */ + async ensureSelfMembership(params: { tenantId: string }): Promise<{ + status: string + role?: string + scopes?: string[] + note?: string + }> { + return (await this._http.post( + `/management/tenants/${encodeURIComponent(params.tenantId)}/members/ensure-self`, + {}, + )) as { status: string; role?: string; scopes?: string[]; note?: string } + } + async listManagementCfPackages(params: { tenantId: string; caseVersion?: '1.0' | '1.1' }): Promise { const caseVersion = params.caseVersion ?? '1.1' const res = (await this._http.get(`/management/tenants/${encodeURIComponent(params.tenantId)}/CFPackages?caseVersion=${encodeURIComponent(caseVersion)}`)) as unknown @@ -337,6 +363,102 @@ export class CaseApiClient { const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/api-keys/${encodeURIComponent(params.keyId)}` await this._http.delete(url) } + + // ── Member Management ─────────────────────────────────────────── + + /** + * List tenant members (Keycloak users with client roles). + * Requires `case.owner` scope. + */ + async listMembers(params: { tenantId: string }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/members` + const res = (await this._http.get(url)) as unknown + if (res && typeof res === 'object' && 'members' in res) { + const obj = res as { members?: unknown } + if (Array.isArray(obj.members)) return obj.members as TenantMember[] + } + return [] + } + + /** + * Add or ensure a member by email and assign a role. + * New users get a temporary password (returned once) and must change it on first login. + * Requires `case.owner` scope. + */ + async createMember(params: { + tenantId: string + email: string + role: TenantMemberRole + }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/members` + const res = (await this._http.post(url, { + email: params.email, + role: params.role, + })) as unknown + if (res && typeof res === 'object') { + const obj = res as { + userId?: string + email?: string + role?: TenantMemberRole + scopes?: string[] + temporaryPassword?: string | null + mustChangePassword?: boolean + created?: boolean + } + if (obj.userId && obj.role) { + return { + userId: obj.userId, + email: obj.email ?? params.email, + username: null, + role: obj.role, + scopes: obj.scopes ?? [], + temporaryPassword: obj.temporaryPassword ?? null, + mustChangePassword: obj.mustChangePassword === true, + created: obj.created === true, + } + } + } + throw new Error('Unexpected create member response') + } + + /** + * Update a member's role. + * Requires `case.owner` scope. + */ + async updateMember(params: { + tenantId: string + userId: string + role: TenantMemberRole + }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/members/${encodeURIComponent(params.userId)}` + const res = (await this._http.patch(url, { role: params.role })) as unknown + if (res && typeof res === 'object') { + const obj = res as { userId?: string; email?: string | null; role?: TenantMemberRole; scopes?: string[] } + if (obj.userId && obj.role) { + return { + userId: obj.userId, + email: obj.email ?? null, + username: null, + role: obj.role, + scopes: obj.scopes ?? [], + } + } + } + throw new Error('Unexpected update member response') + } + + /** + * Remove a member's roles for this tenant. + * Requires `case.owner` scope. + */ + async deleteMember(params: { tenantId: string; userId: string }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/members/${encodeURIComponent(params.userId)}` + await this._http.delete(url) + } } /** Summary of an API key returned by the list endpoint. */ @@ -346,3 +468,13 @@ export type ApiKeySummary = { description: string } +export type TenantMemberRole = 'viewer' | 'author' | 'admin' + +export type TenantMember = { + userId: string + email: string | null + username?: string | null + role: TenantMemberRole | null + scopes: string[] +} + diff --git a/apps/editor/src/infrastructure/caseApi/http.ts b/apps/editor/src/infrastructure/caseApi/http.ts index 848fe82..cde618f 100644 --- a/apps/editor/src/infrastructure/caseApi/http.ts +++ b/apps/editor/src/infrastructure/caseApi/http.ts @@ -2,6 +2,7 @@ export type HttpClient = { get: (_url: string) => Promise post: (_url: string, _body: unknown) => Promise put: (_url: string, _body: unknown) => Promise + patch: (_url: string, _body: unknown) => Promise delete: (_url: string) => Promise } @@ -70,6 +71,7 @@ export function createFetchHttpClient(baseUrl: string, options: FetchHttpClientO get: (url) => doRequest('GET', url), post: (url, body) => doRequest('POST', url, body), put: (url, body) => doRequest('PUT', url, body), + patch: (url, body) => doRequest('PATCH', url, body), delete: (url) => doRequest('DELETE', url), } } diff --git a/apps/editor/src/ui/auth/LoginScreen.tsx b/apps/editor/src/ui/auth/LoginScreen.tsx index 3183168..3166891 100644 --- a/apps/editor/src/ui/auth/LoginScreen.tsx +++ b/apps/editor/src/ui/auth/LoginScreen.tsx @@ -11,6 +11,8 @@ export default function LoginScreen() { const publicApi = useMemo(() => new CaseApiClient(createFetchHttpClient(cfg.opencaseBaseUrl)), [cfg.opencaseBaseUrl]) const [email, setEmail] = useState('') + const [orgId, setOrgId] = useState('') + const [mode, setMode] = useState<'email' | 'org'>('org') const [uiState, setUiState] = useState<'idle' | 'loading'>('idle') const [hint, setHint] = useState(null) @@ -49,6 +51,27 @@ export default function LoginScreen() { } }, [email, publicApi, setTenantId, signIn]) + const continueWithOrgId = useCallback(async () => { + const trimmed = orgId.trim() + if (!trimmed) return + setUiState('loading') + setHint(null) + try { + const res = await publicApi.lookupTenantByOrgId({ orgId: trimmed }) + const resolvedTenantId = res?.tenantId + if (resolvedTenantId) { + setTenantId(resolvedTenantId) + await signIn(resolvedTenantId) + return + } + setHint("If your organization is recognized, you'll be redirected to sign in.") + } catch { + setHint("If your organization is recognized, you'll be redirected to sign in.") + } finally { + setUiState('idle') + } + }, [orgId, publicApi, setTenantId, signIn]) + // Single-tenant mode: show a direct sign-in button (no email/tenant lookup) if (cfg.defaultTenantId) { return ( @@ -75,45 +98,74 @@ export default function LoginScreen() {
Sign in
-
Enter your email to continue to your organization’s sign-in.
+
+ {mode === 'org' + ? 'Enter your organization ID to continue to your organization’s sign-in.' + : 'Enter your email to continue to your organization’s sign-in.'} +
-
- - setEmail(e.target.value)} - placeholder="you@example.org" - autoComplete="email" - inputMode="email" - onKeyDown={(e) => { - if (e.key === 'Enter') void continueWithEmail() - }} - /> +
+ +
+ {mode === 'org' ? ( +
+ + setOrgId(e.target.value)} + placeholder="your-org-id" + autoComplete="organization" + onKeyDown={(e) => { + if (e.key === 'Enter') void continueWithOrgId() + }} + /> +
+ ) : ( +
+ + setEmail(e.target.value)} + placeholder="you@example.org" + autoComplete="email" + inputMode="email" + onKeyDown={(e) => { + if (e.key === 'Enter') void continueWithEmail() + }} + /> +
+ )} + {hint ?
{hint}
: null} {status === 'error' && error ?
{error}
: null}
-
- - - -
API: {cfg.opencaseBaseUrl}
) } - diff --git a/apps/editor/src/ui/home/HomeScreen.tsx b/apps/editor/src/ui/home/HomeScreen.tsx index c0bc7aa..e70a57d 100644 --- a/apps/editor/src/ui/home/HomeScreen.tsx +++ b/apps/editor/src/ui/home/HomeScreen.tsx @@ -1,4 +1,4 @@ -import { PlusIcon, ArrowPathIcon, MagnifyingGlassIcon, FunnelIcon, XMarkIcon, ArrowRightStartOnRectangleIcon, CloudArrowDownIcon, KeyIcon, ArrowUpTrayIcon } from '@heroicons/react/24/solid' +import { PlusIcon, ArrowPathIcon, MagnifyingGlassIcon, FunnelIcon, XMarkIcon, ArrowRightStartOnRectangleIcon, CloudArrowDownIcon, KeyIcon, ArrowUpTrayIcon, UsersIcon } from '@heroicons/react/24/solid' import { CodeBracketSquareIcon } from '@heroicons/react/24/outline' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/ui/shared/components/ui/button' @@ -8,11 +8,13 @@ import CreateFrameworkDialog, { type CreateFrameworkDraft } from '@/ui/home/Crea import ImportFrameworkDialog from '@/ui/home/ImportFrameworkDialog' import UploadFrameworkDialog from '@/ui/home/UploadFrameworkDialog' import ApiKeysDialog from '@/ui/home/ApiKeysDialog' +import MembersDialog from '@/ui/home/MembersDialog' import type { Framework } from '@/domain/framework/model/types' import { useAuth } from '@/app/providers/AuthProvider' import { getAppConfig } from '@/app/config' import { CaseApiClient, type CfDocumentSummary } from '@/infrastructure/caseApi/CaseApiClient' import { createFetchHttpClient } from '@/infrastructure/caseApi/http' +import { tokenHasCaseOwner, decodeJwtPayload } from '@/infrastructure/auth/tokenScopes' import { ADOPTION_STATUS_OPTIONS } from '@/domain/framework/model/adoptionStatus' /** Compute initials from a display name */ @@ -33,7 +35,23 @@ import { } from '@/ui/shared/components/ui/dialog' /** Minimal user avatar dropdown for the hero */ -function UserAvatarMenu({ userName, tenantId: _tenantId, isAuthenticated, onSignOut, onChangePassword, onApiKeys }: Readonly<{ userName?: string; tenantId?: string; isAuthenticated: boolean; onSignOut?: () => void; onChangePassword?: () => void; onApiKeys?: () => void }>) { +function UserAvatarMenu({ + userName, + tenantId: _tenantId, + isAuthenticated, + onSignOut, + onChangePassword, + onApiKeys, + onMembers, +}: Readonly<{ + userName?: string + tenantId?: string + isAuthenticated: boolean + onSignOut?: () => void + onChangePassword?: () => void + onApiKeys?: () => void + onMembers?: () => void +}>) { const [open, setOpen] = useState(false) const rootRef = useRef(null) const avatarText = useMemo(() => initials(userName), [userName]) @@ -48,7 +66,7 @@ function UserAvatarMenu({ userName, tenantId: _tenantId, isAuthenticated, onSign }, [open]) return ( -
+
{open ? ( -
+
{userName ? (
Signed in as {userName}
@@ -80,6 +98,17 @@ function UserAvatarMenu({ userName, tenantId: _tenantId, isAuthenticated, onSign Change password ) : null} + {isAuthenticated && onMembers ? ( + + ) : null} {isAuthenticated && onApiKeys ? ( + ) +} + +export default function MembersDialog({ + open, + onClose, + api, + tenantId, + currentUserId, +}: Readonly<{ + open: boolean + onClose: () => void + api: CaseApiClient + tenantId: string + /** Keycloak user id (JWT sub) — used to avoid removing yourself accidentally without confirm. */ + currentUserId?: string | null +}>) { + const [members, setMembers] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const [email, setEmail] = useState('') + const [role, setRole] = useState('author') + const [creating, setCreating] = useState(false) + const [createdCreds, setCreatedCreds] = useState<{ email: string; temporaryPassword: string } | null>(null) + + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleting, setDeleting] = useState(false) + const [updatingUserId, setUpdatingUserId] = useState(null) + + const loadMembers = useCallback(async () => { + setLoading(true) + setError(null) + try { + const result = await api.listMembers({ tenantId }) + setMembers(result) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + }, [api, tenantId]) + + useEffect(() => { + if (open) void loadMembers() + }, [open, loadMembers]) + + const handleCreate = useCallback(async () => { + const trimmed = email.trim() + if (!trimmed) return + setCreating(true) + setError(null) + try { + const result = await api.createMember({ tenantId, email: trimmed, role }) + setEmail('') + setRole('author') + if (result.temporaryPassword) { + setCreatedCreds({ email: result.email ?? trimmed, temporaryPassword: result.temporaryPassword }) + } + void loadMembers() + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setCreating(false) + } + }, [api, tenantId, email, role, loadMembers]) + + const handleRoleChange = useCallback(async (member: TenantMember, nextRole: TenantMemberRole) => { + if (!member.role || member.role === nextRole) return + setUpdatingUserId(member.userId) + setError(null) + try { + await api.updateMember({ tenantId, userId: member.userId, role: nextRole }) + void loadMembers() + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setUpdatingUserId(null) + } + }, [api, tenantId, loadMembers]) + + const handleDelete = useCallback(async () => { + if (!deleteTarget) return + setDeleting(true) + setError(null) + try { + await api.deleteMember({ tenantId, userId: deleteTarget.userId }) + setDeleteTarget(null) + void loadMembers() + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setDeleting(false) + } + }, [api, tenantId, deleteTarget, loadMembers]) + + const handleClose = useCallback(() => { + setDeleteTarget(null) + setCreatedCreds(null) + setEmail('') + setRole('author') + setError(null) + onClose() + }, [onClose]) + + return ( + <> + { if (!v) handleClose() }}> + + + Members + + Manage who can access this organization and their roles. + New users get a temporary password and must change it on first login. + + + + {error && ( +
+ {error} +
+ )} + + {createdCreds && ( +
+

+ Member added — copy the temporary password now, it will not be shown again. + They will be required to set a new password on first login. +

+
+
+ Email +
+ {createdCreds.email} + +
+
+
+ Temporary password +
+ {createdCreds.temporaryPassword} + +
+
+
+ +
+ )} + +
+ {loading && members.length === 0 && ( +

Loading...

+ )} + + {!loading && members.length === 0 && ( +

+ No members yet. Add someone below. +

+ )} + + {members.map((m) => { + const isSelf = Boolean(currentUserId && m.userId === currentUserId) + return ( +
+
+

+ {m.email ?? m.username ?? m.userId} + {isSelf ? (you) : null} +

+

{roleLabel(m.role)}

+
+ + +
+ ) + })} +
+ +
+
+
+ + setEmail(e.target.value)} + placeholder="colleague@example.org" + className="text-sm" + onKeyDown={(e) => { + if (e.key === 'Enter') void handleCreate() + }} + /> +
+
+ + +
+
+

+ {ROLE_OPTIONS.find((o) => o.value === role)?.hint} +

+ +
+ + + + +
+
+ + { if (!v) setDeleteTarget(null) }}> + + + Remove member + + Remove access for{' '} + + {deleteTarget?.email ?? deleteTarget?.username ?? deleteTarget?.userId} + + ? They will lose access to this organization until added again. + + + + + + + + + + ) +} diff --git a/apps/opencase/docs/DEVELOPER.md b/apps/opencase/docs/DEVELOPER.md index d4e68d4..210b48e 100644 --- a/apps/opencase/docs/DEVELOPER.md +++ b/apps/opencase/docs/DEVELOPER.md @@ -147,16 +147,23 @@ OpenCASE uses Keycloak client roles to control access: | Scope | Description | Use Cases | |-------|-------------|-----------| -| `case.read` | Read-only access to CASE entities | Public API access, viewing frameworks | -| `case.write` | Read and write access to CASE entities | Creating/updating frameworks, items, associations | -| `case.owner` | Per-tenant administrator | Manage accounts, OAuth clients, and tenant data within a specific tenant | -| `case.admin` | System-wide administrator | Create tenants, manage OAuth clients across all tenants | +| `case.read` | Read-only access to CASE entities | Management list/get frameworks, licenses, definitions | +| `case.write` | Read and write access to CASE entities | Creating/updating/deleting frameworks; CGE search/import proxies | +| `case.owner` | Per-tenant administrator | Manage members, OpenCASE API keys, CGE credentials | +| `case.admin` | System-wide administrator | Create/list tenants (`tenant-system` client) | -**Scope Hierarchy:** -- `case.admin` - Highest privilege (system-wide) -- `case.owner` - Tenant-specific administration -- `case.write` - Includes `case.read` permissions -- `case.read` - Basic read access +**Scope Hierarchy** (enforced in middleware): +- `case.owner` implies `case.write` and `case.read` +- `case.write` implies `case.read` +- `case.admin` is orthogonal (system tenant only; does not imply tenant scopes) + +**Member roles** (mapped to client roles via `/management/tenants/{tenantId}/members`): + +| Role | Scopes | +|------|--------| +| `viewer` | `case.read` | +| `author` | `case.read`, `case.write` | +| `admin` | `case.read`, `case.write`, `case.owner` | ### JWT Token Claims @@ -165,9 +172,14 @@ Keycloak-issued access tokens are JWTs containing: - `iss` - Issuer (must match the Keycloak realm URL) - `aud` / `azp` - Audience / authorized party (must match the tenant client id) - `tenantId` - Tenant identifier (injected by Keycloak mapper, required for tenant-scoped operations) -- `scope` - Space-separated list of granted scopes +- `scope` - Space-separated list of granted scopes (from client roles) +- `org_id` - Optional SSO org claim (must equal `tenantId` for ensure-self); claim name configurable via `SSO_ORG_CLAIM` - `sub` - Subject (user ID for authorization_code flow, client ID for client_credentials) +### Tenancy model + +OpenCASE uses a **single Keycloak realm** with **one OAuth client per tenant** (`tenant-{tenantId}`). JWT `tenantId` must match the URL `:tenantId` on all management routes. + --- ## Configuration @@ -298,103 +310,88 @@ When a tenant is created, an admin account is automatically created with: - Role: `admin` - Scopes: `case.read`, `case.write`, `case.owner` -#### Account Management +#### Member Management (Keycloak) **Required Scope:** `case.owner` -**Create Account:** -```bash -POST /management/tenants/{tenantId}/accounts -Authorization: Bearer {access_token} -Content-Type: application/json - -{ - "email": "user@example.com", - "password": "secure-password", - "role": "user", - "autoGeneratePassword": false -} -``` - -**Roles and Default Scopes:** -- `admin` → `case.read`, `case.write`, `case.owner` -- `user` → `case.read`, `case.write` -- `viewer` → `case.read` +Members are Keycloak users with client roles on `tenant-{tenantId}`. -**List Accounts:** +**List members:** ```bash -GET /management/tenants/{tenantId}/accounts +GET /management/tenants/{tenantId}/members Authorization: Bearer {access_token} ``` -**Update Account:** +**Add member:** ```bash -PUT /management/tenants/{tenantId}/accounts/{accountId} +POST /management/tenants/{tenantId}/members Authorization: Bearer {access_token} Content-Type: application/json { - "email": "newemail@example.com", - "password": "new-password" + "email": "user@example.com", + "role": "author" } ``` -**Delete Account:** +Roles: `viewer` | `author` | `admin` (maps to scopes as above). + +**Update member role:** ```bash -DELETE /management/tenants/{tenantId}/accounts/{accountId} +PATCH /management/tenants/{tenantId}/members/{userId} Authorization: Bearer {access_token} +Content-Type: application/json + +{ "role": "admin" } ``` -**Add Tenant Membership:** +**Remove member** (removes this tenant’s client roles only): ```bash -POST /management/tenants/{tenantId}/accounts/{accountId}/memberships +DELETE /management/tenants/{tenantId}/members/{userId} Authorization: Bearer {access_token} -Content-Type: application/json - -{ - "tenantId": "other-tenant-id", - "role": "user" -} ``` -**Remove Tenant Membership:** +**SSO ensure-self** (no `case.*` scope; requires access-token `org_id` === `tenantId`): ```bash -DELETE /management/tenants/{tenantId}/accounts/{accountId}/memberships/{targetTenantId} +POST /management/tenants/{tenantId}/members/ensure-self Authorization: Bearer {access_token} ``` -#### OAuth Client Management +Assigns default `author` roles on first login when the org claim matches. Re-authenticate afterward so the JWT includes the new scopes. -**Required Scope:** `case.owner` or `case.admin` +#### OpenCASE API Keys -**Create OAuth Client:** -```bash -POST /management/tenants/{tenantId}/clients -Authorization: Bearer {access_token} -Content-Type: application/json +**Required Scope:** `case.owner` -{ - "clientId": "optional-client-id", - "clientSecret": "optional-secret", - "grantTypes": ["client_credentials", "authorization_code"], - "scopes": ["case.read", "case.write"], - "active": true, - "autoGenerateSecret": false -} +```bash +GET/POST /management/tenants/{tenantId}/api-keys +DELETE /management/tenants/{tenantId}/api-keys/{keyId} ``` -**List OAuth Clients:** +Each key is a Keycloak confidential client with `client_credentials` and `case.read`. + +#### CASE Global (CGE) credentials and proxies + +**Configure credentials** (`case.owner`): ```bash -GET /management/tenants/{tenantId}/clients -Authorization: Bearer {access_token} +GET/PUT/DELETE /management/tenants/{tenantId}/cge/credentials +POST /management/tenants/{tenantId}/cge/credentials/test ``` -**Delete OAuth Client:** +PUT body: `{ "clientId": "...", "clientSecret": "..." }`. Secrets are encrypted at rest (`CGE_CREDENTIALS_ENCRYPTION_KEY`). GET never returns the secret. + +**Use CGE** (`case.write`) — OpenCASE backend mints org tokens server-side: ```bash -DELETE /management/tenants/{tenantId}/clients/{clientId} -Authorization: Bearer {access_token} +GET /management/tenants/{tenantId}/cge/frameworks +GET /management/tenants/{tenantId}/cge/frameworks/{frameworkId} +POST /management/tenants/{tenantId}/cge/subscriptions +POST /management/tenants/{tenantId}/cge/import ``` +Import body example: `{ "frameworkId": "...", "sourceUri": "https://publisher/.../CFPackages/...", "subscribe": true }`. + +Env: `CGE_TOKEN_URL`, `CGE_API_BASE_URL`, `CGE_CREDENTIALS_ENCRYPTION_KEY`, `SSO_ORG_CLAIM` (default `org_id`). + --- ## Roadmap diff --git a/apps/opencase/src/domain/user/__tests__/memberRoles.test.ts b/apps/opencase/src/domain/user/__tests__/memberRoles.test.ts new file mode 100644 index 0000000..e495405 --- /dev/null +++ b/apps/opencase/src/domain/user/__tests__/memberRoles.test.ts @@ -0,0 +1,37 @@ +import { + memberRoleFromScopes, + scopesForMemberRole, + keycloakRolesForMemberRole, + isMemberRole +} from '../memberRoles' + +describe('memberRoles', () => { + it('maps roles to scopes', () => { + expect(scopesForMemberRole('viewer')).toEqual(['case.read']) + expect(scopesForMemberRole('author')).toEqual(['case.read', 'case.write']) + expect(scopesForMemberRole('admin')).toEqual(['case.read', 'case.write', 'case.owner']) + }) + + it('includes Keycloak-visible membership role labels', () => { + expect(keycloakRolesForMemberRole('author')).toEqual([ + 'case.read', + 'case.write', + 'author' + ]) + }) + + it('derives highest role from scopes or labels', () => { + expect(memberRoleFromScopes(['case.read'])).toBe('viewer') + expect(memberRoleFromScopes(['viewer'])).toBe('viewer') + expect(memberRoleFromScopes(['case.read', 'case.write'])).toBe('author') + expect(memberRoleFromScopes(['author'])).toBe('author') + expect(memberRoleFromScopes(['case.owner', 'case.read'])).toBe('admin') + expect(memberRoleFromScopes(['admin'])).toBe('admin') + expect(memberRoleFromScopes([])).toBeNull() + }) + + it('validates role names', () => { + expect(isMemberRole('viewer')).toBe(true) + expect(isMemberRole('user')).toBe(false) + }) +}) diff --git a/apps/opencase/src/domain/user/memberRoles.ts b/apps/opencase/src/domain/user/memberRoles.ts new file mode 100644 index 0000000..4d857b8 --- /dev/null +++ b/apps/opencase/src/domain/user/memberRoles.ts @@ -0,0 +1,54 @@ +export type MemberRole = 'viewer' | 'author' | 'admin' + +/** JWT / scope roles used by OpenCASE middleware. */ +export const TENANT_CASE_ROLES = ['case.read', 'case.write', 'case.owner'] as const + +/** Human-readable Keycloak client roles shown in the Admin Console and Members UI. */ +export const TENANT_MEMBER_ROLES = ['viewer', 'author', 'admin'] as const + +/** All Keycloak client roles managed by OpenCASE membership. */ +export const TENANT_MEMBERSHIP_ROLES = [ + ...TENANT_CASE_ROLES, + ...TENANT_MEMBER_ROLES +] as const + +export function scopesForMemberRole (role: MemberRole): string[] { + switch (role) { + case 'viewer': + return ['case.read'] + case 'author': + return ['case.read', 'case.write'] + case 'admin': + return ['case.read', 'case.write', 'case.owner'] + default: + return [] + } +} + +/** + * Roles assigned on the Keycloak tenant client for a membership role: + * the human-readable label (viewer/author/admin) plus the case.* scopes for JWT. + */ +export function keycloakRolesForMemberRole (role: MemberRole): string[] { + return [...scopesForMemberRole(role), role] +} + +/** + * Derive the highest member role from a set of client roles. + * Prefers explicit viewer/author/admin labels, then falls back to case.* scopes. + */ +export function memberRoleFromScopes (scopes: string[]): MemberRole | null { + const set = new Set(scopes) + if (set.has('admin') || set.has('case.owner')) return 'admin' + if (set.has('author') || set.has('case.write')) return 'author' + if (set.has('viewer') || set.has('case.read')) return 'viewer' + return null +} + +export function isMemberRole (value: unknown): value is MemberRole { + return value === 'viewer' || value === 'author' || value === 'admin' +} + +export function isManagedMembershipRole (name: string): boolean { + return (TENANT_MEMBERSHIP_ROLES as readonly string[]).includes(name) +} diff --git a/apps/opencase/src/infrastructure/cge/CgeApiClient.ts b/apps/opencase/src/infrastructure/cge/CgeApiClient.ts new file mode 100644 index 0000000..ee6164e --- /dev/null +++ b/apps/opencase/src/infrastructure/cge/CgeApiClient.ts @@ -0,0 +1,120 @@ +import { logger } from '../logging/Logger' +import type { CgeAuthClient } from './CgeAuthClient' +import type { FileCgeCredentialsStore } from './FileCgeCredentialsStore' + +export class CgeApiClient { + constructor ( + private readonly apiBaseUrl: string, + private readonly auth: CgeAuthClient, + private readonly credentialsStore: FileCgeCredentialsStore + ) {} + + private async withToken ( + tenantId: string, + fn: (accessToken: string) => Promise + ): Promise { + const creds = await this.credentialsStore.get(tenantId) + if (!creds) { + throw new Error('CGE credentials are not configured for this tenant') + } + + const token = await this.auth.getAccessToken(creds.clientId, creds.clientSecret) + try { + return await fn(token) + } catch (err: any) { + if (err?.status === 401) { + const refreshed = await this.auth.refreshAccessToken(creds.clientId, creds.clientSecret) + return await fn(refreshed) + } + throw err + } + } + + private async request ( + accessToken: string, + method: string, + path: string, + body?: unknown + ): Promise { + if (!this.apiBaseUrl) { + throw new Error('CGE_API_BASE_URL is not configured') + } + const url = `${this.apiBaseUrl.replace(/\/$/, '')}${path}` + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json' + } + let payload: string | undefined + if (body !== undefined) { + headers['Content-Type'] = 'application/json' + payload = JSON.stringify(body) + } + + const res = await fetch(url, { method, headers, body: payload }) + if (!res.ok) { + const text = await res.text().catch(() => '') + const error: any = new Error(`CGE API error: ${method} ${path} -> ${res.status}. ${text.slice(0, 500)}`) + error.status = res.status + throw error + } + if (res.status === 204) return null + return await res.json().catch(() => null) + } + + async listFrameworks (tenantId: string, query?: Record): Promise { + const qs = query && Object.keys(query).length > 0 + ? `?${new URLSearchParams(query).toString()}` + : '' + return await this.withToken(tenantId, (token) => + this.request(token, 'GET', `/api/coalition/frameworks${qs}`) + ) + } + + async getFramework (tenantId: string, frameworkId: string): Promise { + return await this.withToken(tenantId, (token) => + this.request(token, 'GET', `/api/coalition/frameworks/${encodeURIComponent(frameworkId)}`) + ) + } + + async createSubscription (tenantId: string, body: unknown): Promise { + return await this.withToken(tenantId, (token) => + this.request(token, 'POST', '/api/coalition/subscriptions', body) + ) + } + + /** + * Fetch a CFPackage from a publisher CASE host, presenting the CGE JWT. + */ + async fetchPublisherPackage (tenantId: string, endpointUrl: string): Promise { + return await this.withToken(tenantId, async (token) => { + const res = await fetch(endpointUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json' + } + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + const error: any = new Error(`Publisher fetch failed: ${res.status}. ${text.slice(0, 500)}`) + error.status = res.status + throw error + } + return await res.json() + }) + } + + async testCredentials (tenantId: string): Promise<{ ok: boolean, message: string }> { + try { + const creds = await this.credentialsStore.get(tenantId) + if (!creds) { + return { ok: false, message: 'No credentials configured' } + } + await this.auth.refreshAccessToken(creds.clientId, creds.clientSecret) + return { ok: true, message: 'Token minted successfully' } + } catch (error: any) { + logger.warn({ tenantId, error: error?.message }, 'CGE credential test failed') + return { ok: false, message: error?.message || 'Token request failed' } + } + } +} diff --git a/apps/opencase/src/infrastructure/cge/CgeAuthClient.ts b/apps/opencase/src/infrastructure/cge/CgeAuthClient.ts new file mode 100644 index 0000000..f6783b7 --- /dev/null +++ b/apps/opencase/src/infrastructure/cge/CgeAuthClient.ts @@ -0,0 +1,72 @@ +import { logger } from '../logging/Logger' + +type TokenCache = { + accessToken: string + expiresAtMs: number +} + +/** + * OAuth2 client_credentials token client for CASE Global (org API key). + */ +export class CgeAuthClient { + private cache = new Map() + + constructor ( + private readonly tokenUrl: string + ) {} + + async getAccessToken (clientId: string, clientSecret: string): Promise { + const cacheKey = clientId + const cached = this.cache.get(cacheKey) + if (cached && Date.now() < cached.expiresAtMs) { + return cached.accessToken + } + + return await this.fetchToken(clientId, clientSecret) + } + + /** Force mint a new token (e.g. after HTTP 401). */ + async refreshAccessToken (clientId: string, clientSecret: string): Promise { + this.cache.delete(clientId) + return await this.fetchToken(clientId, clientSecret) + } + + private async fetchToken (clientId: string, clientSecret: string): Promise { + if (!this.tokenUrl) { + throw new Error('CGE_TOKEN_URL is not configured') + } + + const params = new URLSearchParams() + params.set('grant_type', 'client_credentials') + params.set('client_id', clientId) + params.set('client_secret', clientSecret) + + const res = await fetch(this.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json' + }, + body: params.toString() + }) + + if (!res.ok) { + const body = await res.text().catch(() => '') + logger.warn({ status: res.status, body: body.slice(0, 500) }, 'CGE token request failed') + throw new Error(`Failed to obtain CGE access token: ${res.status} ${res.statusText}`) + } + + const json = await res.json() as { access_token?: string, expires_in?: number } + if (!json.access_token) { + throw new Error('CGE token response missing access_token') + } + + const expiresIn = typeof json.expires_in === 'number' ? json.expires_in : 300 + this.cache.set(clientId, { + accessToken: json.access_token, + expiresAtMs: Date.now() + (expiresIn * 1000) - 30_000 + }) + + return json.access_token + } +} diff --git a/apps/opencase/src/infrastructure/cge/FileCgeCredentialsStore.ts b/apps/opencase/src/infrastructure/cge/FileCgeCredentialsStore.ts new file mode 100644 index 0000000..f8d5978 --- /dev/null +++ b/apps/opencase/src/infrastructure/cge/FileCgeCredentialsStore.ts @@ -0,0 +1,117 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto' +import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { existsSync } from 'node:fs' + +export interface CgeCredentials { + clientId: string + clientSecret: string + updatedAt: string +} + +export interface CgeCredentialsPublic { + configured: boolean + clientIdMasked: string | null + updatedAt: string | null +} + +/** + * File-based per-tenant CGE credential store. + * Secrets are encrypted at rest when an encryption key is configured. + */ +export class FileCgeCredentialsStore { + constructor ( + private readonly baseDataDir: string, + private readonly encryptionKey: string | undefined + ) {} + + private credentialsPath (tenantId: string): string { + return join(this.baseDataDir, 'tenants', tenantId, 'cge', 'credentials.json') + } + + private deriveKey (): Buffer { + if (!this.encryptionKey) { + throw new Error('CGE_CREDENTIALS_ENCRYPTION_KEY is required to store CGE credentials') + } + // scrypt with fixed salt scoped to this app purpose + return scryptSync(this.encryptionKey, 'opencase-cge-credentials', 32) + } + + private encrypt (plaintext: string): string { + const key = this.deriveKey() + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', key, iv) + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + return `v1:${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}` + } + + private decrypt (payload: string): string { + if (!payload.startsWith('v1:')) { + // Legacy/dev plaintext fallback when key was not used + return payload + } + const parts = payload.split(':') + if (parts.length !== 4) throw new Error('Invalid encrypted credential payload') + const [, ivB64, tagB64, dataB64] = parts + const key = this.deriveKey() + const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64')) + decipher.setAuthTag(Buffer.from(tagB64, 'base64')) + return Buffer.concat([ + decipher.update(Buffer.from(dataB64, 'base64')), + decipher.final() + ]).toString('utf8') + } + + async get (tenantId: string): Promise { + const path = this.credentialsPath(tenantId) + if (!existsSync(path)) return null + const raw = JSON.parse(await readFile(path, 'utf8')) as { + clientId: string + clientSecret: string + updatedAt: string + } + return { + clientId: raw.clientId, + clientSecret: this.decrypt(raw.clientSecret), + updatedAt: raw.updatedAt + } + } + + async getPublic (tenantId: string): Promise { + const creds = await this.get(tenantId) + if (!creds) { + return { configured: false, clientIdMasked: null, updatedAt: null } + } + const id = creds.clientId + const masked = id.length <= 8 ? '********' : `${id.slice(0, 4)}…${id.slice(-4)}` + return { + configured: true, + clientIdMasked: masked, + updatedAt: creds.updatedAt + } + } + + async put (tenantId: string, clientId: string, clientSecret: string): Promise { + if (!this.encryptionKey) { + throw new Error('CGE_CREDENTIALS_ENCRYPTION_KEY is required to store CGE credentials') + } + const path = this.credentialsPath(tenantId) + await mkdir(dirname(path), { recursive: true }) + const updatedAt = new Date().toISOString() + const payload = { + clientId, + clientSecret: this.encrypt(clientSecret), + updatedAt + } + await writeFile(path, JSON.stringify(payload, null, 2), 'utf8') + return await this.getPublic(tenantId) + } + + async delete (tenantId: string): Promise { + const path = this.credentialsPath(tenantId) + if (existsSync(path)) { + await unlink(path) + } + } +} diff --git a/apps/opencase/src/infrastructure/cge/__tests__/CgeAuthClient.test.ts b/apps/opencase/src/infrastructure/cge/__tests__/CgeAuthClient.test.ts new file mode 100644 index 0000000..a903fe0 --- /dev/null +++ b/apps/opencase/src/infrastructure/cge/__tests__/CgeAuthClient.test.ts @@ -0,0 +1,44 @@ +import { CgeAuthClient } from '../CgeAuthClient' + +describe('CgeAuthClient', () => { + const originalFetch = global.fetch + + afterEach(() => { + global.fetch = originalFetch + }) + + it('mints and caches access tokens', async () => { + let calls = 0 + global.fetch = jest.fn(async () => { + calls += 1 + return { + ok: true, + json: async () => ({ access_token: 'tok-1', expires_in: 3600 }) + } as any + }) as any + + const client = new CgeAuthClient('https://cge.example/token') + const t1 = await client.getAccessToken('cid', 'sec') + const t2 = await client.getAccessToken('cid', 'sec') + expect(t1).toBe('tok-1') + expect(t2).toBe('tok-1') + expect(calls).toBe(1) + }) + + it('refreshAccessToken bypasses cache', async () => { + let calls = 0 + global.fetch = jest.fn(async () => { + calls += 1 + return { + ok: true, + json: async () => ({ access_token: `tok-${calls}`, expires_in: 3600 }) + } as any + }) as any + + const client = new CgeAuthClient('https://cge.example/token') + await client.getAccessToken('cid', 'sec') + const refreshed = await client.refreshAccessToken('cid', 'sec') + expect(refreshed).toBe('tok-2') + expect(calls).toBe(2) + }) +}) diff --git a/apps/opencase/src/infrastructure/cge/__tests__/FileCgeCredentialsStore.test.ts b/apps/opencase/src/infrastructure/cge/__tests__/FileCgeCredentialsStore.test.ts new file mode 100644 index 0000000..2544c7c --- /dev/null +++ b/apps/opencase/src/infrastructure/cge/__tests__/FileCgeCredentialsStore.test.ts @@ -0,0 +1,35 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { FileCgeCredentialsStore } from '../FileCgeCredentialsStore' + +describe('FileCgeCredentialsStore', () => { + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'cge-creds-')) + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('refuses to store without encryption key', async () => { + const store = new FileCgeCredentialsStore(dir, undefined) + await expect(store.put('t1', 'cid', 'sec')).rejects.toThrow(/CGE_CREDENTIALS_ENCRYPTION_KEY/) + }) + + it('stores and retrieves encrypted credentials', async () => { + const store = new FileCgeCredentialsStore(dir, 'test-encryption-key-32chars!!') + const pub = await store.put('demo', 'my-client-id', 'super-secret') + expect(pub.configured).toBe(true) + expect(pub.clientIdMasked).toContain('…') + + const creds = await store.get('demo') + expect(creds?.clientId).toBe('my-client-id') + expect(creds?.clientSecret).toBe('super-secret') + + await store.delete('demo') + expect(await store.get('demo')).toBeNull() + }) +}) diff --git a/apps/opencase/src/infrastructure/config/Config.ts b/apps/opencase/src/infrastructure/config/Config.ts index c97dba5..eabae3d 100644 --- a/apps/opencase/src/infrastructure/config/Config.ts +++ b/apps/opencase/src/infrastructure/config/Config.ts @@ -42,6 +42,22 @@ export interface AppConfig { // Keycloak realm SSL enforcement ('none' for HTTP dev, 'external' for production HTTPS) keycloakRealmSslRequired: 'none' | 'external' | 'all'; + + /** + * Access-token claim from federated IdP (Auth0) carrying the organization id. + * Convention: claim value equals OpenCASE tenantId (1:1). + */ + ssoOrgClaim: string; + + /** CGE OAuth token endpoint (client_credentials). */ + cgeTokenUrl: string; + /** CGE coalition API base URL (e.g. https://cge.example.com). */ + cgeApiBaseUrl: string; + /** + * Encryption key for per-tenant CGE client secrets at rest. + * Required to store credentials (PUT). + */ + cgeCredentialsEncryptionKey?: string; } export function loadConfig(): AppConfig { @@ -83,6 +99,12 @@ export function loadConfig(): AppConfig { smtpFrom: process.env.SMTP_FROM ?? 'noreply@opencase.local', keycloakRealmSslRequired: (process.env.KEYCLOAK_SSL_REQUIRED ?? (isProduction ? 'external' : 'none')) as 'none' | 'external' | 'all', + + ssoOrgClaim: process.env.SSO_ORG_CLAIM ?? 'org_id', + + cgeTokenUrl: process.env.CGE_TOKEN_URL ?? '', + cgeApiBaseUrl: process.env.CGE_API_BASE_URL ?? '', + cgeCredentialsEncryptionKey: process.env.CGE_CREDENTIALS_ENCRYPTION_KEY, }; } diff --git a/apps/opencase/src/infrastructure/config/__tests__/Config.test.ts b/apps/opencase/src/infrastructure/config/__tests__/Config.test.ts index 6579bb9..212a522 100644 --- a/apps/opencase/src/infrastructure/config/__tests__/Config.test.ts +++ b/apps/opencase/src/infrastructure/config/__tests__/Config.test.ts @@ -49,6 +49,9 @@ describe('Config', () => { expect(config.keycloakBootstrapSystemAdmin).toBe(true); expect(config.keycloakSystemAdminEmail).toBe('system-admin@local'); expect(config.keycloakSystemAdminPassword).toBe('admin'); + expect(config.ssoOrgClaim).toBe('org_id'); + expect(config.cgeTokenUrl).toBe(''); + expect(config.cgeApiBaseUrl).toBe(''); }); it('should default KEYCLOAK_BOOTSTRAP_SYSTEM_ADMIN to false in production when not set', () => { diff --git a/apps/opencase/src/infrastructure/keycloak/KeycloakAdminClient.ts b/apps/opencase/src/infrastructure/keycloak/KeycloakAdminClient.ts index 86ac155..9ad45a5 100644 --- a/apps/opencase/src/infrastructure/keycloak/KeycloakAdminClient.ts +++ b/apps/opencase/src/infrastructure/keycloak/KeycloakAdminClient.ts @@ -107,17 +107,119 @@ export class KeycloakAdminClient { return { id: created.id } } - async ensureClientRole (clientUuid: string, roleName: string): Promise { + async ensureClientRole (clientUuid: string, roleName: string, description?: string): Promise { const realm = this.cfg.realm const existing = await this.requestRaw('GET', `/admin/realms/${encodeURIComponent(realm)}/clients/${encodeURIComponent(clientUuid)}/roles/${encodeURIComponent(roleName)}`) - if (existing.status === 200) return + if (existing.status === 200) { + if (description) { + const role = await existing.json().catch(() => null) as any + if (role && role.description !== description) { + await this.requestJson('PUT', `/admin/realms/${encodeURIComponent(realm)}/clients/${encodeURIComponent(clientUuid)}/roles/${encodeURIComponent(roleName)}`, { + ...role, + description + }) + } + } + return + } if (existing.status !== 404) throw new Error(`Failed to check role '${roleName}': ${existing.status} ${existing.statusText}`) await this.requestJson('POST', `/admin/realms/${encodeURIComponent(realm)}/clients/${encodeURIComponent(clientUuid)}/roles`, { - name: roleName + name: roleName, + description: description ?? '' }) } + async getClientRole (clientUuid: string, roleName: string): Promise { + const realm = this.cfg.realm + const res = await this.requestRaw( + 'GET', + `/admin/realms/${encodeURIComponent(realm)}/clients/${encodeURIComponent(clientUuid)}/roles/${encodeURIComponent(roleName)}` + ) + if (res.status === 404) return null + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`Failed to get role '${roleName}': ${res.status}. ${text}`) + } + return await res.json() + } + + /** + * Ensure case.* scope roles and viewer/author/admin membership roles exist on a tenant client. + * Membership roles are composites of the corresponding case.* roles so they appear clearly in Keycloak. + */ + async ensureTenantMemberRoles (clientUuid: string): Promise { + await this.ensureClientRole(clientUuid, 'case.read', 'Read frameworks (case.read)') + await this.ensureClientRole(clientUuid, 'case.write', 'Author frameworks (case.write)') + await this.ensureClientRole(clientUuid, 'case.owner', 'Tenant administrator (case.owner)') + + await this.ensureClientRole(clientUuid, 'viewer', 'Viewer — read frameworks') + await this.ensureClientRole(clientUuid, 'author', 'Author — create and edit frameworks') + await this.ensureClientRole(clientUuid, 'admin', 'Admin — manage members, API keys, and credentials') + + await this.ensureClientRoleComposites(clientUuid, 'viewer', ['case.read']) + await this.ensureClientRoleComposites(clientUuid, 'author', ['case.read', 'case.write']) + await this.ensureClientRoleComposites(clientUuid, 'admin', ['case.read', 'case.write', 'case.owner']) + } + + private async ensureClientRoleComposites ( + clientUuid: string, + compositeRoleName: string, + childRoleNames: string[] + ): Promise { + const realm = this.cfg.realm + const existing = await this.requestJson( + 'GET', + `/admin/realms/${encodeURIComponent(realm)}/clients/${encodeURIComponent(clientUuid)}/roles/${encodeURIComponent(compositeRoleName)}/composites` + ) + const existingNames = new Set( + (Array.isArray(existing) ? existing : []).map((r: any) => r?.name as string).filter(Boolean) + ) + const missing = childRoleNames.filter(n => !existingNames.has(n)) + if (missing.length === 0) return + + const children: any[] = [] + for (const name of missing) { + const role = await this.getClientRole(clientUuid, name) + if (role) children.push(role) + } + if (children.length === 0) return + + await this.requestJson( + 'POST', + `/admin/realms/${encodeURIComponent(realm)}/clients/${encodeURIComponent(clientUuid)}/roles/${encodeURIComponent(compositeRoleName)}/composites`, + children + ) + } + + /** + * Set membership for a user on a tenant client to exactly one MemberRole. + * Assigns the human-readable role (viewer/author/admin) plus case.* scopes for JWT mappers. + * Only touches OpenCASE-managed roles; leaves other client roles alone. + */ + async setMemberRole ( + userId: string, + clientUuid: string, + desiredRoleNames: string[] + ): Promise { + await this.ensureTenantMemberRoles(clientUuid) + + const managed = new Set([ + 'case.read', 'case.write', 'case.owner', + 'viewer', 'author', 'admin' + ]) + const desired = new Set(desiredRoleNames) + const current = await this.getUserClientRoleMappings(userId, clientUuid) + const currentNames = current.map((r: any) => r?.name as string).filter(Boolean) + const currentManaged = currentNames.filter(n => managed.has(n)) + + const toRemove = currentManaged.filter(n => !desired.has(n)) + const toAdd = [...desired].filter(n => !currentManaged.includes(n)) + + if (toRemove.length > 0) await this.removeClientRoles(userId, clientUuid, toRemove) + if (toAdd.length > 0) await this.assignClientRoles(userId, clientUuid, toAdd) + } + async ensureProtocolMapper (clientUuid: string, mapper: any): Promise { const realm = this.cfg.realm const list = await this.requestJson('GET', `/admin/realms/${encodeURIComponent(realm)}/clients/${encodeURIComponent(clientUuid)}/protocol-mappers/models`) @@ -127,11 +229,11 @@ export class KeycloakAdminClient { await this.requestJson('POST', `/admin/realms/${encodeURIComponent(realm)}/clients/${encodeURIComponent(clientUuid)}/protocol-mappers/models`, mapper) } - async ensureUser (user: { username: string, email?: string, enabled?: boolean }): Promise<{ id: string }> { + async ensureUser (user: { username: string, email?: string, enabled?: boolean }): Promise<{ id: string, created: boolean }> { const realm = this.cfg.realm const users = await this.requestJson('GET', `/admin/realms/${encodeURIComponent(realm)}/users?username=${encodeURIComponent(user.username)}&exact=true`) const existing = Array.isArray(users) ? users[0] : undefined - if (existing?.id) return { id: existing.id } + if (existing?.id) return { id: existing.id, created: false } await this.requestJson('POST', `/admin/realms/${encodeURIComponent(realm)}/users`, { username: user.username, @@ -143,7 +245,7 @@ export class KeycloakAdminClient { const users2 = await this.requestJson('GET', `/admin/realms/${encodeURIComponent(realm)}/users?username=${encodeURIComponent(user.username)}&exact=true`) const created = Array.isArray(users2) ? users2[0] : undefined if (!created?.id) throw new Error(`Failed to create Keycloak user '${user.username}'`) - return { id: created.id } + return { id: created.id, created: true } } async findUserByEmailExact (email: string): Promise<{ id: string, username?: string, email?: string } | null> { @@ -195,6 +297,96 @@ export class KeycloakAdminClient { await this.requestJson('POST', `/admin/realms/${encodeURIComponent(realm)}/users/${encodeURIComponent(userId)}/role-mappings/clients/${encodeURIComponent(clientUuid)}`, roles) } + async removeClientRoles (userId: string, clientUuid: string, roleNames: string[]): Promise { + if (roleNames.length === 0) return + const realm = this.cfg.realm + const roles: any[] = [] + for (const roleName of roleNames) { + const role = await this.requestJson('GET', `/admin/realms/${encodeURIComponent(realm)}/clients/${encodeURIComponent(clientUuid)}/roles/${encodeURIComponent(roleName)}`) + roles.push(role) + } + + const res = await this.requestRaw( + 'DELETE', + `/admin/realms/${encodeURIComponent(realm)}/users/${encodeURIComponent(userId)}/role-mappings/clients/${encodeURIComponent(clientUuid)}`, + roles + ) + if (!res.ok && res.status !== 204) { + const text = await res.text().catch(() => '') + throw new Error(`Failed to remove client roles: ${res.status} ${res.statusText}. ${text}`) + } + } + + /** + * Replace a user's client roles for a given client with exactly `roleNames`. + */ + async setClientRoles (userId: string, clientUuid: string, roleNames: string[]): Promise { + const current = await this.getUserClientRoleMappings(userId, clientUuid) + const currentNames = current.map((r: any) => r?.name as string).filter(Boolean) + const toRemove = currentNames.filter(n => !roleNames.includes(n)) + const toAdd = roleNames.filter(n => !currentNames.includes(n)) + if (toRemove.length > 0) await this.removeClientRoles(userId, clientUuid, toRemove) + if (toAdd.length > 0) await this.assignClientRoles(userId, clientUuid, toAdd) + } + + async getUserById (userId: string): Promise<{ id: string, username?: string, email?: string, enabled?: boolean } | null> { + const realm = this.cfg.realm + const res = await this.requestRaw('GET', `/admin/realms/${encodeURIComponent(realm)}/users/${encodeURIComponent(userId)}`) + if (res.status === 404) return null + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`Keycloak Admin API error: GET user ${userId} -> ${res.status}. ${text}`) + } + const u = await res.json().catch(() => null) as any + if (!u?.id) return null + return { id: u.id, username: u.username, email: u.email, enabled: u.enabled } + } + + /** + * List users that have any of the given client roles on a tenant client. + * Uses Keycloak role-user listing per role and de-duplicates. + */ + async listUsersWithClientRoles ( + clientUuid: string, + roleNames: string[] + ): Promise> { + const realm = this.cfg.realm + const byId = new Map }>() + + for (const roleName of roleNames) { + const users = await this.requestJson( + 'GET', + `/admin/realms/${encodeURIComponent(realm)}/clients/${encodeURIComponent(clientUuid)}/roles/${encodeURIComponent(roleName)}/users?max=5000` + ) + if (!Array.isArray(users)) continue + for (const u of users) { + if (!u?.id) continue + const existing = byId.get(u.id) + if (existing) { + existing.roles.add(roleName) + } else { + byId.set(u.id, { + id: u.id, + username: u.username, + email: u.email, + roles: new Set([roleName]) + }) + } + } + } + + return [...byId.values()].map(u => ({ + id: u.id, + username: u.username, + email: u.email, + roles: [...u.roles].sort() + })) + } + + async findClientByClientIdPublic (clientId: string): Promise<{ id: string, clientId: string } | null> { + return await this.findClientByClientId(clientId) + } + // ── OAuth2 client scope helpers ────────────────────────────────── /** diff --git a/apps/opencase/src/infrastructure/keycloak/KeycloakTenantProvisioner.ts b/apps/opencase/src/infrastructure/keycloak/KeycloakTenantProvisioner.ts index 7aecb89..8a5fe19 100644 --- a/apps/opencase/src/infrastructure/keycloak/KeycloakTenantProvisioner.ts +++ b/apps/opencase/src/infrastructure/keycloak/KeycloakTenantProvisioner.ts @@ -47,6 +47,8 @@ export class KeycloakTenantProvisioner { }) await this.admin.ensureClientRole(clientUuid, 'case.admin') + // Also provision membership roles so system-admin can use Members / API Keys on the system tenant + await this.admin.ensureTenantMemberRoles(clientUuid) await this.admin.ensureProtocolMapper(clientUuid, { name: 'tenantId', @@ -81,6 +83,10 @@ export class KeycloakTenantProvisioner { const { id: userId } = await this.admin.ensureUser({ username: email, email, enabled: true }) await this.admin.setUserPassword(userId, password, false) await this.admin.assignClientRoles(userId, clientUuid, ['case.admin']) + // Tenant-admin capabilities on the system tenant (Members, API keys, framework authoring) + await this.admin.setMemberRole(userId, clientUuid, [ + 'case.read', 'case.write', 'case.owner', 'admin' + ]) logger.info({ clientId, email }, 'Bootstrapped system admin user in Keycloak') } @@ -97,10 +103,8 @@ export class KeycloakTenantProvisioner { webOrigins: this.cfg.spaWebOrigins }) - const roles = ['case.read', 'case.write', 'case.owner'] - for (const role of roles) { - await this.admin.ensureClientRole(clientUuid, role) - } + // Scope roles + human-readable membership roles (viewer/author/admin) + await this.admin.ensureTenantMemberRoles(clientUuid) await this.admin.ensureProtocolMapper(clientUuid, { name: 'tenantId', @@ -136,7 +140,10 @@ export class KeycloakTenantProvisioner { const { id: userId } = await this.admin.ensureUser({ username: adminEmail, email: adminEmail, enabled: true }) const adminPassword = randomBytes(18).toString('base64url') await this.admin.setUserPassword(userId, adminPassword, true) - await this.admin.assignClientRoles(userId, clientUuid, roles) + // Assign admin membership (label + case.* scopes) + await this.admin.setMemberRole(userId, clientUuid, [ + 'case.read', 'case.write', 'case.owner', 'admin' + ]) logger.info({ tenantId, clientId, adminEmail }, 'Provisioned tenant client and admin user in Keycloak') return { adminEmail, adminPassword } diff --git a/apps/opencase/src/interfaces/http/http-management/controllers/CFPackagesManagementController.ts b/apps/opencase/src/interfaces/http/http-management/controllers/CFPackagesManagementController.ts index e90e886..3b5d4b9 100644 --- a/apps/opencase/src/interfaces/http/http-management/controllers/CFPackagesManagementController.ts +++ b/apps/opencase/src/interfaces/http/http-management/controllers/CFPackagesManagementController.ts @@ -7,6 +7,7 @@ import { type RestoreFramework } from '../../../../application/case/endpoints/Re import { type ListFrameworks } from '../../../../application/case/endpoints/ListFrameworks' import { getParam } from '../../utils/expressParams' import { getCaseVersion } from '../../utils/caseVersion' +import { requireMatchingTenant } from '../../middleware/tenantAccess' export class CFPackagesManagementController { constructor ( @@ -19,15 +20,12 @@ export class CFPackagesManagementController { list: RequestHandler<{ tenantId: string }> = async (req: Request, res: Response) => { try { - const tenantId = (req as any).tenantId ?? req.params.tenantId - const urlTenantId = getParam(req, 'tenantId') + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return + // For GET endpoints, `caseVersion` remains a filter param (no override needed). const caseVersion = getCaseVersion(req) - if (urlTenantId && urlTenantId !== tenantId) { - return res.status(403).json({ error: 'Tenant mismatch - authenticated tenant does not match URL parameter' }) - } - // Extract includeArchived query parameter (default: false) const includeArchived = req.query.includeArchived === 'true' @@ -39,12 +37,11 @@ export class CFPackagesManagementController { } create: RequestHandler<{ tenantId: string }> = async (req: Request, res: Response) => { - const tenantId = getParam(req, 'tenantId') + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return const caseVersion = getCaseVersion(req, { default: '1.1' })! try { - if (!tenantId) return res.status(400).json({ error: 'Missing tenantId' }) - if (!req.body.CFDocument) { return res.status(400).json({ error: 'missing_required_field', @@ -78,7 +75,8 @@ export class CFPackagesManagementController { } import: RequestHandler<{ tenantId: string }> = async (req: Request, res: Response) => { - const tenantId = getParam(req, 'tenantId') + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return const caseVersion = getCaseVersion(req, { default: '1.1' })! const { endpointUrl, accessToken, cfPackage, validateSchema, schemaName } = req.body @@ -87,7 +85,6 @@ export class CFPackagesManagementController { } try { - if (!tenantId) return res.status(400).json({ error: 'Missing tenantId' }) const result = await this.importFramework.execute({ tenantId, caseVersion, @@ -114,14 +111,11 @@ export class CFPackagesManagementController { delete: RequestHandler<{ tenantId: string, id: string }> = async (req: Request, res: Response) => { try { - const tenantId = (req as any).tenantId ?? req.params.tenantId - const urlTenantId = getParam(req, 'tenantId') + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return const id = getParam(req, 'id') const caseVersion = getCaseVersion(req, { default: '1.1' })! - if (urlTenantId && urlTenantId !== tenantId) { - return res.status(403).json({ error: 'Tenant mismatch - authenticated tenant does not match URL parameter' }) - } if (!id) return res.status(400).json({ error: 'Missing id' }) // Extract hardDelete query parameter (default: false = soft delete/archive) @@ -145,14 +139,11 @@ export class CFPackagesManagementController { restore: RequestHandler<{ tenantId: string, id: string }> = async (req: Request, res: Response) => { try { - const tenantId = (req as any).tenantId ?? req.params.tenantId - const urlTenantId = getParam(req, 'tenantId') + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return const id = getParam(req, 'id') const caseVersion = getCaseVersion(req, { default: '1.1' })! - if (urlTenantId && urlTenantId !== tenantId) { - return res.status(403).json({ error: 'Tenant mismatch - authenticated tenant does not match URL parameter' }) - } if (!id) return res.status(400).json({ error: 'Missing id' }) await this.restoreFrameworkUseCase.execute({ diff --git a/apps/opencase/src/interfaces/http/http-management/controllers/CgeManagementController.ts b/apps/opencase/src/interfaces/http/http-management/controllers/CgeManagementController.ts new file mode 100644 index 0000000..cb5f121 --- /dev/null +++ b/apps/opencase/src/interfaces/http/http-management/controllers/CgeManagementController.ts @@ -0,0 +1,194 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import type { Request, Response, RequestHandler } from 'express' +import type { FileCgeCredentialsStore } from '../../../../infrastructure/cge/FileCgeCredentialsStore' +import type { CgeApiClient } from '../../../../infrastructure/cge/CgeApiClient' +import type { ImportFramework } from '../../../../application/case/endpoints/ImportFramework' +import { requireMatchingTenant } from '../../middleware/tenantAccess' +import { getParam } from '../../utils/expressParams' +import { logger } from '../../../../infrastructure/logging/Logger' + +export class CgeManagementController { + constructor ( + private readonly credentialsStore: FileCgeCredentialsStore, + private readonly cgeApi: CgeApiClient, + private readonly importFramework: ImportFramework + ) {} + + getCredentials: RequestHandler = async (req: Request, res: Response) => { + try { + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return + const pub = await this.credentialsStore.getPublic(tenantId) + return res.status(200).json(pub) + } catch (error: any) { + return res.status(400).json({ error: error?.message || 'Failed to get credentials' }) + } + } + + putCredentials: RequestHandler = async (req: Request, res: Response) => { + try { + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return + + const clientId = typeof req.body?.clientId === 'string' ? req.body.clientId.trim() : '' + const clientSecret = typeof req.body?.clientSecret === 'string' ? req.body.clientSecret : '' + if (!clientId || !clientSecret) { + return res.status(400).json({ error: 'clientId and clientSecret are required' }) + } + + const pub = await this.credentialsStore.put(tenantId, clientId, clientSecret) + logger.info({ tenantId, sub: (req as any).user?.sub }, 'CGE credentials updated') + return res.status(200).json(pub) + } catch (error: any) { + return res.status(400).json({ error: error?.message || 'Failed to store credentials' }) + } + } + + deleteCredentials: RequestHandler = async (req: Request, res: Response) => { + try { + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return + await this.credentialsStore.delete(tenantId) + logger.info({ tenantId, sub: (req as any).user?.sub }, 'CGE credentials deleted') + return res.status(200).json({ status: 'deleted' }) + } catch (error: any) { + return res.status(400).json({ error: error?.message || 'Failed to delete credentials' }) + } + } + + testCredentials: RequestHandler = async (req: Request, res: Response) => { + try { + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return + const result = await this.cgeApi.testCredentials(tenantId) + return res.status(result.ok ? 200 : 400).json(result) + } catch (error: any) { + return res.status(400).json({ ok: false, message: error?.message || 'Test failed' }) + } + } + + listFrameworks: RequestHandler = async (req: Request, res: Response) => { + try { + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return + + const query: Record = {} + for (const [k, v] of Object.entries(req.query)) { + if (typeof v === 'string') query[k] = v + } + + const data = await this.cgeApi.listFrameworks(tenantId, query) + logger.info({ + tenantId, + sub: (req as any).user?.sub, + email: (req as any).user?.email + }, 'CGE frameworks search') + return res.status(200).json(data) + } catch (error: any) { + const status = error?.status === 401 ? 502 : 400 + return res.status(status).json({ error: error?.message || 'CGE list failed' }) + } + } + + getFramework: RequestHandler = async (req: Request, res: Response) => { + try { + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return + const frameworkId = getParam(req, 'frameworkId') + if (!frameworkId) return res.status(400).json({ error: 'Missing frameworkId' }) + + const data = await this.cgeApi.getFramework(tenantId, frameworkId) + return res.status(200).json(data) + } catch (error: any) { + const status = error?.status === 404 ? 404 : 400 + return res.status(status).json({ error: error?.message || 'CGE get failed' }) + } + } + + createSubscription: RequestHandler = async (req: Request, res: Response) => { + try { + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return + + const data = await this.cgeApi.createSubscription(tenantId, req.body ?? {}) + logger.info({ + tenantId, + sub: (req as any).user?.sub, + body: req.body + }, 'CGE subscription created') + return res.status(201).json(data ?? { status: 'created' }) + } catch (error: any) { + return res.status(400).json({ error: error?.message || 'CGE subscribe failed' }) + } + } + + importFromCge: RequestHandler = async (req: Request, res: Response) => { + try { + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return + + const frameworkId = typeof req.body?.frameworkId === 'string' ? req.body.frameworkId.trim() : '' + const sourceUri = typeof req.body?.sourceUri === 'string' ? req.body.sourceUri.trim() : '' + const subscribe = req.body?.subscribe !== false + const caseVersion = (req.body?.caseVersion === '1.0' ? '1.0' : '1.1') as '1.0' | '1.1' + + let endpointUrl = sourceUri + let detail: any = null + + if (frameworkId) { + detail = await this.cgeApi.getFramework(tenantId, frameworkId) + endpointUrl = endpointUrl || + detail?.sourceUri || + detail?.source_uri || + detail?.uri || + detail?.CFDocument?.uri || + detail?.packageUri || + '' + } + + if (!endpointUrl) { + return res.status(400).json({ + error: 'sourceUri or frameworkId with a resolvable publisher URI is required' + }) + } + + if (subscribe && frameworkId) { + try { + await this.cgeApi.createSubscription(tenantId, { + frameworkId, + ...(req.body?.subscription ?? {}) + }) + } catch (subErr: any) { + // Subscription may already exist; continue with fetch + logger.warn({ tenantId, frameworkId, error: subErr?.message }, 'CGE subscribe during import (continuing)') + } + } + + const cfPackage = await this.cgeApi.fetchPublisherPackage(tenantId, endpointUrl) + const result = await this.importFramework.execute({ + tenantId, + caseVersion, + cfPackage, + validateSchema: req.body?.validateSchema ?? false + }) + + logger.info({ + tenantId, + sub: (req as any).user?.sub, + email: (req as any).user?.email, + frameworkId: frameworkId || undefined, + docId: result.docId + }, 'CGE framework imported') + + return res.status(201).json({ + status: 'imported', + id: result.docId, + version: result.version, + sourceUri: endpointUrl, + frameworkId: frameworkId || undefined + }) + } catch (error: any) { + return res.status(400).json({ error: 'cge_import_failed', message: error?.message }) + } + } +} diff --git a/apps/opencase/src/interfaces/http/http-management/controllers/MembersManagementController.ts b/apps/opencase/src/interfaces/http/http-management/controllers/MembersManagementController.ts new file mode 100644 index 0000000..79445a9 --- /dev/null +++ b/apps/opencase/src/interfaces/http/http-management/controllers/MembersManagementController.ts @@ -0,0 +1,234 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import type { Request, Response, RequestHandler } from 'express' +import { randomBytes } from 'node:crypto' +import type { KeycloakAdminClient } from '../../../../infrastructure/keycloak/KeycloakAdminClient' +import { OidcJwtVerifier } from '../../../../infrastructure/auth/OidcJwtVerifier' +import { requireMatchingTenant } from '../../middleware/tenantAccess' +import { getParam } from '../../utils/expressParams' +import { + isManagedMembershipRole, + isMemberRole, + keycloakRolesForMemberRole, + memberRoleFromScopes, + TENANT_MEMBERSHIP_ROLES, + type MemberRole +} from '../../../../domain/user/memberRoles' +import { logger } from '../../../../infrastructure/logging/Logger' + +/** + * Tenant member management via Keycloak client roles. + * Keycloak remains the source of truth for membership. + * + * Roles visible in Keycloak Admin Console (Clients → tenant-{id} → Roles): + * viewer, author, admin (composites of case.read / case.write / case.owner) + */ +export class MembersManagementController { + constructor ( + private readonly admin: KeycloakAdminClient, + private readonly cfg: { + clientIdPrefix: string + /** Access-token claim carrying Auth0 org id (default org_id). */ + ssoOrgClaim: string + } + ) {} + + private async resolveTenantClient (tenantId: string): Promise<{ id: string, clientId: string } | null> { + const clientId = OidcJwtVerifier.computeTenantClientId(this.cfg.clientIdPrefix, tenantId) + return await this.admin.findClientByClientIdPublic(clientId) + } + + list: RequestHandler = async (req: Request, res: Response) => { + try { + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return + + const client = await this.resolveTenantClient(tenantId) + if (!client) return res.status(404).json({ error: 'Tenant client not found' }) + + await this.admin.ensureTenantMemberRoles(client.id) + + const users = await this.admin.listUsersWithClientRoles(client.id, [...TENANT_MEMBERSHIP_ROLES]) + const members = users.map(u => ({ + userId: u.id, + email: u.email ?? null, + username: u.username ?? null, + role: memberRoleFromScopes(u.roles), + scopes: u.roles.filter(isManagedMembershipRole) + })) + + return res.status(200).json({ members }) + } catch (error: any) { + logger.error({ error: error?.message }, 'Failed to list members') + return res.status(400).json({ error: error?.message || 'Failed to list members' }) + } + } + + create: RequestHandler = async (req: Request, res: Response) => { + try { + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return + + const email = typeof req.body?.email === 'string' ? req.body.email.trim() : '' + const role = req.body?.role as MemberRole + if (!email) return res.status(400).json({ error: 'email is required' }) + if (!isMemberRole(role)) { + return res.status(400).json({ error: 'role must be one of: viewer, author, admin' }) + } + + const client = await this.resolveTenantClient(tenantId) + if (!client) return res.status(404).json({ error: 'Tenant client not found' }) + + const { id: userId, created } = await this.admin.ensureUser({ + username: email, + email, + enabled: true + }) + + let temporaryPassword: string | null = null + if (created) { + temporaryPassword = randomBytes(12).toString('base64url') + // temporary=true forces UPDATE_PASSWORD on next Keycloak login + await this.admin.setUserPassword(userId, temporaryPassword, true) + } + + const roles = keycloakRolesForMemberRole(role) + await this.admin.setMemberRole(userId, client.id, roles) + + return res.status(201).json({ + userId, + email, + role, + scopes: roles, + temporaryPassword, + mustChangePassword: temporaryPassword !== null, + created + }) + } catch (error: any) { + logger.error({ error: error?.message }, 'Failed to create member') + return res.status(400).json({ error: error?.message || 'Failed to create member' }) + } + } + + update: RequestHandler = async (req: Request, res: Response) => { + try { + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return + + const userId = getParam(req, 'userId') + if (!userId) return res.status(400).json({ error: 'Missing userId' }) + + const role = req.body?.role as MemberRole + if (!isMemberRole(role)) { + return res.status(400).json({ error: 'role must be one of: viewer, author, admin' }) + } + + const client = await this.resolveTenantClient(tenantId) + if (!client) return res.status(404).json({ error: 'Tenant client not found' }) + + const user = await this.admin.getUserById(userId) + if (!user) return res.status(404).json({ error: 'User not found' }) + + const roles = keycloakRolesForMemberRole(role) + await this.admin.setMemberRole(userId, client.id, roles) + + return res.status(200).json({ + userId, + email: user.email ?? null, + role, + scopes: roles + }) + } catch (error: any) { + logger.error({ error: error?.message }, 'Failed to update member') + return res.status(400).json({ error: error?.message || 'Failed to update member' }) + } + } + + remove: RequestHandler = async (req: Request, res: Response) => { + try { + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return + + const userId = getParam(req, 'userId') + if (!userId) return res.status(400).json({ error: 'Missing userId' }) + + const client = await this.resolveTenantClient(tenantId) + if (!client) return res.status(404).json({ error: 'Tenant client not found' }) + + const current = await this.admin.getUserClientRoleMappings(userId, client.id) + const currentNames = current.map((r: any) => r?.name as string).filter(Boolean) + const toRemove = currentNames.filter(isManagedMembershipRole) + if (toRemove.length > 0) { + await this.admin.removeClientRoles(userId, client.id, toRemove) + } + + return res.status(200).json({ status: 'removed', userId }) + } catch (error: any) { + logger.error({ error: error?.message }, 'Failed to remove member') + return res.status(400).json({ error: error?.message || 'Failed to remove member' }) + } + } + + /** + * SSO first-login: assign default author roles when org claim matches tenant + * and the user has no tenant client roles yet. + */ + ensureSelf: RequestHandler = async (req: Request, res: Response) => { + try { + const tenantId = requireMatchingTenant(req, res) + if (!tenantId) return + + const user = (req as any).user as Record | undefined + const claimName = this.cfg.ssoOrgClaim + const orgClaim = user?.[claimName] + const orgId = typeof orgClaim === 'string' + ? orgClaim + : Array.isArray(orgClaim) + ? String(orgClaim[0] ?? '') + : '' + + if (!orgId || orgId !== tenantId) { + return res.status(403).json({ + error: 'Forbidden', + message: `Access token claim '${claimName}' must match tenantId for ensure-self` + }) + } + + const sub = typeof user?.sub === 'string' ? user.sub : undefined + if (!sub) return res.status(401).json({ error: 'Unauthorized - missing sub' }) + + const client = await this.resolveTenantClient(tenantId) + if (!client) return res.status(404).json({ error: 'Tenant client not found' }) + + await this.admin.ensureTenantMemberRoles(client.id) + + const current = await this.admin.getUserClientRoleMappings(sub, client.id) + const currentNames = current.map((r: any) => r?.name as string).filter(Boolean) + const existingRole = memberRoleFromScopes(currentNames) + if (existingRole) { + return res.status(200).json({ + status: 'unchanged', + userId: sub, + role: existingRole, + scopes: currentNames.filter(isManagedMembershipRole) + }) + } + + const role: MemberRole = 'author' + const roles = keycloakRolesForMemberRole(role) + await this.admin.setMemberRole(sub, client.id, roles) + + logger.info({ tenantId, userId: sub }, 'SSO ensure-self assigned default author roles') + + return res.status(200).json({ + status: 'assigned', + userId: sub, + role, + scopes: roles, + note: 'Re-authenticate to refresh access token scopes' + }) + } catch (error: any) { + logger.error({ error: error?.message }, 'Failed ensure-self membership') + return res.status(400).json({ error: error?.message || 'Failed to ensure membership' }) + } + } +} diff --git a/apps/opencase/src/interfaces/http/http-management/controllers/__tests__/CFPackagesManagementController.test.ts b/apps/opencase/src/interfaces/http/http-management/controllers/__tests__/CFPackagesManagementController.test.ts index 235d2fd..3d85abb 100644 --- a/apps/opencase/src/interfaces/http/http-management/controllers/__tests__/CFPackagesManagementController.test.ts +++ b/apps/opencase/src/interfaces/http/http-management/controllers/__tests__/CFPackagesManagementController.test.ts @@ -84,6 +84,17 @@ describe('CFPackagesManagementController', () => { expect(responseJson).toHaveBeenCalledWith({ status: 'archived', id: 'doc-1' }) }) + it('rejects create when JWT tenant does not match URL', async () => { + ;(mockRequest as any).tenantId = 'other-tenant' + mockRequest.params = { tenantId: 'test-tenant' } as any + mockRequest.body = { CFDocument: { identifier: 'x' } } + + await (controller.create as any)(mockRequest as Request, mockResponse as Response, next) + + expect(responseStatus).toHaveBeenCalledWith(403) + expect(mockCreateFramework.execute).not.toHaveBeenCalled() + }) + it('performs hard delete when hardDelete=true', async () => { ;(mockRequest as any).tenantId = 'test-tenant' mockRequest.params = { tenantId: 'test-tenant', id: 'doc-1' } as any @@ -120,6 +131,7 @@ describe('CFPackagesManagementController', () => { }) it('imports a framework from an endpointUrl', async () => { + ;(mockRequest as any).tenantId = 'test-tenant' mockRequest.body = { endpointUrl: 'https://example.org/CFPackages/doc-1' } mockImportFramework.execute.mockResolvedValueOnce({ docId: 'doc-1', version: 1 }) @@ -139,6 +151,7 @@ describe('CFPackagesManagementController', () => { }) it('imports a framework from a pasted cfPackage payload', async () => { + ;(mockRequest as any).tenantId = 'test-tenant' const cfPackage = { CFDocument: { identifier: 'doc-2' } } mockRequest.body = { cfPackage } mockImportFramework.execute.mockResolvedValueOnce({ docId: 'doc-2', version: 1 }) @@ -159,6 +172,7 @@ describe('CFPackagesManagementController', () => { }) it('rejects import when neither endpointUrl nor cfPackage is provided', async () => { + ;(mockRequest as any).tenantId = 'test-tenant' mockRequest.body = {} await (controller.import as any)(mockRequest as Request, mockResponse as Response, next) diff --git a/apps/opencase/src/interfaces/http/http-management/routes.ts b/apps/opencase/src/interfaces/http/http-management/routes.ts index 8d7e1a6..d580d98 100644 --- a/apps/opencase/src/interfaces/http/http-management/routes.ts +++ b/apps/opencase/src/interfaces/http/http-management/routes.ts @@ -5,6 +5,8 @@ import type { CFAssociationsManagementController } from './controllers/CFAssocia import type { CFPackagesManagementController } from './controllers/CFPackagesManagementController' import type { TenantsManagementController } from './controllers/TenantsManagementController' import type { ApiKeysManagementController } from './controllers/ApiKeysManagementController' +import type { MembersManagementController } from './controllers/MembersManagementController' +import type { CgeManagementController } from './controllers/CgeManagementController' import type { FileFrameworkStore } from '../../../infrastructure/persistence/file/FileFrameworkStore' import { requireScope, requireAnyScope } from '../middleware/scope' @@ -33,6 +35,8 @@ export interface ManagementDeps { cfPackagesController: CFPackagesManagementController tenantsController: TenantsManagementController apiKeysController?: ApiKeysManagementController + membersController?: MembersManagementController + cgeController?: CgeManagementController store?: FileFrameworkStore } @@ -257,92 +261,113 @@ export function registerManagementRoutes (app: Express, deps: ManagementDeps): v // CFPackage list/create/import/delete endpoints (non-CASE extension) app.get( '/management/tenants/:tenantId/CFPackages', + requireScope('case.read'), deps.cfPackagesController.list ) app.post( '/management/tenants/:tenantId/ims/case/v1p0/CFPackages', + requireScope('case.write'), withCaseVersion('1.0', deps.cfPackagesController.create as unknown as RequestHandler) ) app.post( '/management/tenants/:tenantId/ims/case/v1p1/CFPackages', + requireScope('case.write'), withCaseVersion('1.1', deps.cfPackagesController.create as unknown as RequestHandler) ) app.post( '/management/tenants/:tenantId/ims/case/v1p0/CFPackages/import', + requireScope('case.write'), withCaseVersion('1.0', deps.cfPackagesController.import as unknown as RequestHandler) ) app.post( '/management/tenants/:tenantId/ims/case/v1p1/CFPackages/import', + requireScope('case.write'), withCaseVersion('1.1', deps.cfPackagesController.import as unknown as RequestHandler) ) // CASE entity management endpoints (explicit version in the path) app.put( '/management/tenants/:tenantId/ims/case/v1p0/CFDocuments/:id', + requireScope('case.write'), withCaseVersion('1.0', deps.cfDocumentsController.update as unknown as RequestHandler) ) app.delete( '/management/tenants/:tenantId/ims/case/v1p0/CFDocuments/:id', + requireScope('case.write'), withCaseVersion('1.0', deps.cfDocumentsController.delete as unknown as RequestHandler) ) app.put( '/management/tenants/:tenantId/ims/case/v1p1/CFDocuments/:id', + requireScope('case.write'), withCaseVersion('1.1', deps.cfDocumentsController.update as unknown as RequestHandler) ) app.delete( '/management/tenants/:tenantId/ims/case/v1p1/CFDocuments/:id', + requireScope('case.write'), withCaseVersion('1.1', deps.cfDocumentsController.delete as unknown as RequestHandler) ) app.put( '/management/tenants/:tenantId/ims/case/v1p0/CFItems/:id', + requireScope('case.write'), withCaseVersion('1.0', deps.cfItemsController.update as unknown as RequestHandler) ) app.delete( '/management/tenants/:tenantId/ims/case/v1p0/CFItems/:id', + requireScope('case.write'), withCaseVersion('1.0', deps.cfItemsController.delete as unknown as RequestHandler) ) app.put( '/management/tenants/:tenantId/ims/case/v1p1/CFItems/:id', + requireScope('case.write'), withCaseVersion('1.1', deps.cfItemsController.update as unknown as RequestHandler) ) app.delete( '/management/tenants/:tenantId/ims/case/v1p1/CFItems/:id', + requireScope('case.write'), withCaseVersion('1.1', deps.cfItemsController.delete as unknown as RequestHandler) ) app.put( '/management/tenants/:tenantId/ims/case/v1p0/CFAssociations/:id', + requireScope('case.write'), withCaseVersion('1.0', deps.cfAssociationsController.update as unknown as RequestHandler) ) app.delete( '/management/tenants/:tenantId/ims/case/v1p0/CFAssociations/:id', + requireScope('case.write'), withCaseVersion('1.0', deps.cfAssociationsController.delete as unknown as RequestHandler) ) app.put( '/management/tenants/:tenantId/ims/case/v1p1/CFAssociations/:id', + requireScope('case.write'), withCaseVersion('1.1', deps.cfAssociationsController.update as unknown as RequestHandler) ) app.delete( '/management/tenants/:tenantId/ims/case/v1p1/CFAssociations/:id', + requireScope('case.write'), withCaseVersion('1.1', deps.cfAssociationsController.delete as unknown as RequestHandler) ) app.delete( '/management/tenants/:tenantId/ims/case/v1p0/CFPackages/:id', + requireScope('case.write'), withCaseVersion('1.0', deps.cfPackagesController.delete as unknown as RequestHandler) ) app.delete( '/management/tenants/:tenantId/ims/case/v1p1/CFPackages/:id', + requireScope('case.write'), withCaseVersion('1.1', deps.cfPackagesController.delete as unknown as RequestHandler) ) // Restore (unarchive) a previously archived framework app.post( '/management/tenants/:tenantId/ims/case/v1p0/CFPackages/:id/restore', + requireScope('case.write'), withCaseVersion('1.0', deps.cfPackagesController.restore as unknown as RequestHandler) ) app.post( '/management/tenants/:tenantId/ims/case/v1p1/CFPackages/:id/restore', + requireScope('case.write'), withCaseVersion('1.1', deps.cfPackagesController.restore as unknown as RequestHandler) ) @@ -362,6 +387,7 @@ export function registerManagementRoutes (app: Express, deps: ManagementDeps): v if (deps.store) { app.get( '/management/tenants/:tenantId/licenses', + requireScope('case.read'), (req: Request, res: Response) => { try { const tenantId = (req as any).tenantId ?? req.params.tenantId @@ -383,6 +409,7 @@ export function registerManagementRoutes (app: Express, deps: ManagementDeps): v // for use by the editor's combobox / picker UI. app.get( '/management/tenants/:tenantId/definitions', + requireScope('case.read'), (req: Request, res: Response) => { try { const tenantId = (req as any).tenantId ?? req.params.tenantId @@ -407,7 +434,7 @@ export function registerManagementRoutes (app: Express, deps: ManagementDeps): v ) } - // API key management endpoints (require case.owner or case.admin scope) + // API key management endpoints (tenant owner or system case.admin) if (deps.apiKeysController) { app.get( '/management/tenants/:tenantId/api-keys', @@ -426,4 +453,78 @@ export function registerManagementRoutes (app: Express, deps: ManagementDeps): v ) } + // Member management (Keycloak client roles) + if (deps.membersController) { + // SSO first-login ensure — authenticated, no case.* scope required (register before :userId) + app.post( + '/management/tenants/:tenantId/members/ensure-self', + deps.membersController.ensureSelf + ) + app.get( + '/management/tenants/:tenantId/members', + requireAnyScope('case.owner', 'case.admin'), + deps.membersController.list + ) + app.post( + '/management/tenants/:tenantId/members', + requireAnyScope('case.owner', 'case.admin'), + deps.membersController.create + ) + app.patch( + '/management/tenants/:tenantId/members/:userId', + requireAnyScope('case.owner', 'case.admin'), + deps.membersController.update + ) + app.delete( + '/management/tenants/:tenantId/members/:userId', + requireAnyScope('case.owner', 'case.admin'), + deps.membersController.remove + ) + } + + // CASE Global credentials + proxy APIs + if (deps.cgeController) { + app.get( + '/management/tenants/:tenantId/cge/credentials', + requireAnyScope('case.owner', 'case.admin'), + deps.cgeController.getCredentials + ) + app.put( + '/management/tenants/:tenantId/cge/credentials', + requireAnyScope('case.owner', 'case.admin'), + deps.cgeController.putCredentials + ) + app.delete( + '/management/tenants/:tenantId/cge/credentials', + requireAnyScope('case.owner', 'case.admin'), + deps.cgeController.deleteCredentials + ) + app.post( + '/management/tenants/:tenantId/cge/credentials/test', + requireAnyScope('case.owner', 'case.admin'), + deps.cgeController.testCredentials + ) + + app.get( + '/management/tenants/:tenantId/cge/frameworks', + requireScope('case.write'), + deps.cgeController.listFrameworks + ) + app.get( + '/management/tenants/:tenantId/cge/frameworks/:frameworkId', + requireScope('case.write'), + deps.cgeController.getFramework + ) + app.post( + '/management/tenants/:tenantId/cge/subscriptions', + requireScope('case.write'), + deps.cgeController.createSubscription + ) + app.post( + '/management/tenants/:tenantId/cge/import', + requireScope('case.write'), + deps.cgeController.importFromCge + ) + } + } diff --git a/apps/opencase/src/interfaces/http/http-public/public/controllers/TenantLookupController.ts b/apps/opencase/src/interfaces/http/http-public/public/controllers/TenantLookupController.ts index a7c467a..395b60a 100644 --- a/apps/opencase/src/interfaces/http/http-public/public/controllers/TenantLookupController.ts +++ b/apps/opencase/src/interfaces/http/http-public/public/controllers/TenantLookupController.ts @@ -1,5 +1,6 @@ import type { Request, Response, RequestHandler } from 'express' import type { KeycloakAdminClient } from '../../../../../infrastructure/keycloak/KeycloakAdminClient' +import { OidcJwtVerifier } from '../../../../../infrastructure/auth/OidcJwtVerifier' import { logger } from '../../../../../infrastructure/logging/Logger' export class TenantLookupController { @@ -11,10 +12,26 @@ export class TenantLookupController { ) {} lookup: RequestHandler = async (req: Request, res: Response) => { - // Anti-enumeration: always 202, regardless of whether the email exists. + // Anti-enumeration: always 202, regardless of whether the email/org exists. res.status(202) res.setHeader('Cache-Control', 'no-store') + const rawOrgId = (req.query as any)?.orgId as string | string[] | undefined + const orgId = (Array.isArray(rawOrgId) ? rawOrgId[0] : rawOrgId)?.trim() + if (orgId) { + try { + const clientId = OidcJwtVerifier.computeTenantClientId(this.cfg.clientIdPrefix, orgId) + const client = await this.keycloakAdmin.findClientByClientIdPublic(clientId) + if (client) { + return res.json({ status: 'accepted', tenantId: orgId }) + } + return res.json({ status: 'accepted' }) + } catch (error: any) { + logger.warn({ error: error?.message, orgId }, 'Tenant lookup by orgId failed') + return res.json({ status: 'accepted' }) + } + } + const rawEmail = (req.query as any)?.email as string | string[] | undefined const email = (Array.isArray(rawEmail) ? rawEmail[0] : rawEmail)?.trim() if (!email) return res.json({ status: 'accepted' }) @@ -63,4 +80,3 @@ export class TenantLookupController { return null } } - diff --git a/apps/opencase/src/interfaces/http/http-public/public/controllers/__tests__/TenantLookupController.test.ts b/apps/opencase/src/interfaces/http/http-public/public/controllers/__tests__/TenantLookupController.test.ts index 237daf6..362264f 100644 --- a/apps/opencase/src/interfaces/http/http-public/public/controllers/__tests__/TenantLookupController.test.ts +++ b/apps/opencase/src/interfaces/http/http-public/public/controllers/__tests__/TenantLookupController.test.ts @@ -47,5 +47,34 @@ describe('TenantLookupController', () => { expect(res.status).toHaveBeenCalledWith(202) expect((res.json as any).mock.calls[0][0]).toEqual({ status: 'accepted', tenantId: 'demo' }) }) + + it('returns 202 with tenantId when orgId matches an existing tenant client', async () => { + const keycloakAdmin: any = { + findClientByClientIdPublic: jest.fn().mockResolvedValue({ id: 'c1', clientId: 'tenant-acme-org' }) + } + const c = new TenantLookupController(keycloakAdmin, { clientIdPrefix: 'tenant-' }) + const req = { query: { orgId: 'acme-org' } } as unknown as Request + const res = makeRes() + + await c.lookup(req, res, jest.fn() as any) + + expect(res.status).toHaveBeenCalledWith(202) + expect((res.json as any).mock.calls[0][0]).toEqual({ status: 'accepted', tenantId: 'acme-org' }) + expect(keycloakAdmin.findClientByClientIdPublic).toHaveBeenCalledWith('tenant-acme-org') + }) + + it('returns 202 without tenantId when orgId client does not exist', async () => { + const keycloakAdmin: any = { + findClientByClientIdPublic: jest.fn().mockResolvedValue(null) + } + const c = new TenantLookupController(keycloakAdmin, { clientIdPrefix: 'tenant-' }) + const req = { query: { orgId: 'missing' } } as unknown as Request + const res = makeRes() + + await c.lookup(req, res, jest.fn() as any) + + expect(res.status).toHaveBeenCalledWith(202) + expect((res.json as any).mock.calls[0][0]).toEqual({ status: 'accepted' }) + }) }) diff --git a/apps/opencase/src/interfaces/http/http-public/public/routes.ts b/apps/opencase/src/interfaces/http/http-public/public/routes.ts index ab3d934..2067959 100644 --- a/apps/opencase/src/interfaces/http/http-public/public/routes.ts +++ b/apps/opencase/src/interfaces/http/http-public/public/routes.ts @@ -10,14 +10,16 @@ export interface PublicDeps { * /public/tenant-lookup: * get: * operationId: publicTenantLookup - * summary: Tenant lookup by email (anti-enumeration; always 202) + * summary: Tenant lookup by email or orgId (anti-enumeration; always 202) * description: | * Used by SPAs to discover a tenantId for Keycloak client-per-tenant login. + * Pass either `email` (membership lookup) or `orgId` (tenant client existence; Auth0 org === tenantId). * Always responds with 202 Accepted. The response body may include tenantId if a mapping exists. * tags: [Public] * security: [] * parameters: - * - { name: email, in: query, required: true, schema: { type: string, format: email } } + * - { name: email, in: query, required: false, schema: { type: string, format: email } } + * - { name: orgId, in: query, required: false, schema: { type: string } } * responses: * 202: { description: Accepted } */ diff --git a/apps/opencase/src/interfaces/http/middleware/__tests__/scope.test.ts b/apps/opencase/src/interfaces/http/middleware/__tests__/scope.test.ts index 434c615..4f1d6b2 100644 --- a/apps/opencase/src/interfaces/http/middleware/__tests__/scope.test.ts +++ b/apps/opencase/src/interfaces/http/middleware/__tests__/scope.test.ts @@ -1,5 +1,35 @@ import { Request, Response, NextFunction } from 'express' -import { requireScope } from '../scope' +import { requireScope, expandScopes, userHasScope } from '../scope' + +describe('expandScopes', () => { + it('expands case.owner to include write and read', () => { + expect([...expandScopes(['case.owner'])].sort()).toEqual([ + 'case.owner', + 'case.read', + 'case.write' + ]) + }) + + it('expands admin membership role to case.owner', () => { + expect([...expandScopes(['admin'])].sort()).toEqual([ + 'admin', + 'case.owner', + 'case.read', + 'case.write' + ]) + }) + + it('expands case.write to include read', () => { + expect([...expandScopes(['case.write'])].sort()).toEqual([ + 'case.read', + 'case.write' + ]) + }) + + it('does not expand case.admin into tenant scopes', () => { + expect([...expandScopes(['case.admin'])]).toEqual(['case.admin']) + }) +}) describe('requireScope', () => { let mockRequest: Partial @@ -36,6 +66,43 @@ describe('requireScope', () => { expect(responseStatus).not.toHaveBeenCalled() }) + it('should allow case.owner to satisfy case.write', () => { + const middleware = requireScope('case.write') + ;(mockRequest as any).user = { scope: 'case.owner' } + + middleware(mockRequest as Request, mockResponse as Response, mockNext) + + expect(mockNext).toHaveBeenCalled() + }) + + it('should allow admin membership role to satisfy case.owner', () => { + const middleware = requireScope('case.owner') + ;(mockRequest as any).user = { scope: 'admin' } + + middleware(mockRequest as Request, mockResponse as Response, mockNext) + + expect(mockNext).toHaveBeenCalled() + }) + + it('should allow case.write to satisfy case.read', () => { + const middleware = requireScope('case.read') + ;(mockRequest as any).user = { scope: 'case.write' } + + middleware(mockRequest as Request, mockResponse as Response, mockNext) + + expect(mockNext).toHaveBeenCalled() + }) + + it('should not allow case.write to satisfy case.owner', () => { + const middleware = requireScope('case.owner') + ;(mockRequest as any).user = { scope: 'case.write' } + + middleware(mockRequest as Request, mockResponse as Response, mockNext) + + expect(responseStatus).toHaveBeenCalledWith(403) + expect(mockNext).not.toHaveBeenCalled() + }) + it('should return 401 when user is not set', () => { const middleware = requireScope('case.admin') ;(mockRequest as any).user = undefined @@ -115,17 +182,15 @@ describe('requireScope', () => { expect(responseStatus).toHaveBeenCalledWith(403) expect(mockNext).not.toHaveBeenCalled() }) -}) - - - - - - - - - - - - + it('userHasScope reads resource_access roles', () => { + const user = { + resource_access: { + 'tenant-demo': { roles: ['case.write'] } + } + } + expect(userHasScope(user, 'case.read')).toBe(true) + expect(userHasScope(user, 'case.write')).toBe(true) + expect(userHasScope(user, 'case.owner')).toBe(false) + }) +}) diff --git a/apps/opencase/src/interfaces/http/middleware/__tests__/tenantAccess.test.ts b/apps/opencase/src/interfaces/http/middleware/__tests__/tenantAccess.test.ts new file mode 100644 index 0000000..e17fe04 --- /dev/null +++ b/apps/opencase/src/interfaces/http/middleware/__tests__/tenantAccess.test.ts @@ -0,0 +1,27 @@ +import type { Request, Response } from 'express' +import { requireMatchingTenant } from '../tenantAccess' + +describe('requireMatchingTenant', () => { + it('returns tenantId when JWT and URL match', () => { + const req = { + params: { tenantId: 'demo' }, + tenantId: 'demo' + } as any as Request + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() } as any as Response + + expect(requireMatchingTenant(req, res)).toBe('demo') + expect(res.status).not.toHaveBeenCalled() + }) + + it('returns 403 on mismatch', () => { + const req = { + params: { tenantId: 'other' }, + tenantId: 'demo' + } as any as Request + const json = jest.fn() + const res = { status: jest.fn().mockReturnValue({ json }), json } as any as Response + + expect(requireMatchingTenant(req, res)).toBeNull() + expect(res.status).toHaveBeenCalledWith(403) + }) +}) diff --git a/apps/opencase/src/interfaces/http/middleware/scope.ts b/apps/opencase/src/interfaces/http/middleware/scope.ts index 0c915b3..aa405e1 100644 --- a/apps/opencase/src/interfaces/http/middleware/scope.ts +++ b/apps/opencase/src/interfaces/http/middleware/scope.ts @@ -1,21 +1,76 @@ import { Request, Response, NextFunction } from 'express' +/** Hierarchy: membership labels + case.* scopes. case.admin (system) is orthogonal. */ +const SCOPE_IMPLIES: Record = { + admin: ['case.owner', 'case.write', 'case.read'], + author: ['case.write', 'case.read'], + viewer: ['case.read'], + 'case.owner': ['case.write', 'case.read'], + 'case.write': ['case.read'], +} + +export function expandScopes (scopes: string[]): Set { + const expanded = new Set(scopes) + // Fixed-point expansion so owner → write → read + let changed = true + while (changed) { + changed = false + for (const s of [...expanded]) { + for (const implied of SCOPE_IMPLIES[s] ?? []) { + if (!expanded.has(implied)) { + expanded.add(implied) + changed = true + } + } + } + } + return expanded +} + +export function normalizeScopes (user: any): string[] { + const raw = user?.scope + const scopes: string[] = [] + if (typeof raw === 'string') { + scopes.push(...raw.split(' ').filter(Boolean)) + } else if (Array.isArray(raw)) { + scopes.push(...raw.map(String).filter(Boolean)) + } + + // Keycloak fallback: accept client/realm roles as scopes + const realmRoles = user?.realm_access?.roles + if (Array.isArray(realmRoles)) scopes.push(...realmRoles.map(String)) + + const resourceAccess = user?.resource_access + if (resourceAccess && typeof resourceAccess === 'object') { + for (const client of Object.values(resourceAccess as Record)) { + const roles = client?.roles + if (Array.isArray(roles)) scopes.push(...roles.map(String)) + } + } + + return scopes.filter(Boolean) +} + +export function userHasScope (user: any, requiredScope: string): boolean { + const expanded = expandScopes(normalizeScopes(user)) + return expanded.has(requiredScope) +} + /** - * Creates middleware that requires a specific scope in the JWT token + * Creates middleware that requires a specific scope in the JWT token. + * Applies hierarchy: case.owner satisfies case.write and case.read; case.write satisfies case.read. */ -export function requireScope(requiredScope: string) { +export function requireScope (requiredScope: string) { return (req: Request, res: Response, next: NextFunction) => { const user = (req as any).user if (!user) { return res.status(401).json({ error: 'Unauthorized - no user information' }) } - const scopes = normalizeScopes(user) - - if (!scopes.includes(requiredScope)) { - return res.status(403).json({ - error: 'Forbidden', - message: `Required scope '${requiredScope}' not found in token` + if (!userHasScope(user, requiredScope)) { + return res.status(403).json({ + error: 'Forbidden', + message: `Required scope '${requiredScope}' not found in token` }) } @@ -24,52 +79,24 @@ export function requireScope(requiredScope: string) { } /** - * Creates middleware that requires any of the specified scopes in the JWT token + * Creates middleware that requires any of the specified scopes in the JWT token. */ -export function requireAnyScope(...requiredScopes: string[]) { +export function requireAnyScope (...requiredScopes: string[]) { return (req: Request, res: Response, next: NextFunction) => { const user = (req as any).user if (!user) { return res.status(401).json({ error: 'Unauthorized - no user information' }) } - const scopes = normalizeScopes(user) - - const hasRequiredScope = requiredScopes.some(scope => scopes.includes(scope)) - + const hasRequiredScope = requiredScopes.some(scope => userHasScope(user, scope)) + if (!hasRequiredScope) { - return res.status(403).json({ - error: 'Forbidden', - message: `Required scope(s) '${requiredScopes.join(' or ')}' not found in token` + return res.status(403).json({ + error: 'Forbidden', + message: `Required scope(s) '${requiredScopes.join(' or ')}' not found in token` }) } return next() } } - -function normalizeScopes (user: any): string[] { - const raw = user?.scope - if (typeof raw === 'string') { - return raw.split(' ').filter(Boolean) - } - if (Array.isArray(raw)) { - return raw.map(String).filter(Boolean) - } - - // Keycloak fallback: accept client/realm roles as scopes - const scopes: string[] = [] - const realmRoles = user?.realm_access?.roles - if (Array.isArray(realmRoles)) scopes.push(...realmRoles.map(String)) - - const resourceAccess = user?.resource_access - if (resourceAccess && typeof resourceAccess === 'object') { - for (const client of Object.values(resourceAccess as Record)) { - const roles = client?.roles - if (Array.isArray(roles)) scopes.push(...roles.map(String)) - } - } - - return scopes.filter(Boolean) -} - diff --git a/apps/opencase/src/interfaces/http/middleware/tenantAccess.ts b/apps/opencase/src/interfaces/http/middleware/tenantAccess.ts new file mode 100644 index 0000000..221123f --- /dev/null +++ b/apps/opencase/src/interfaces/http/middleware/tenantAccess.ts @@ -0,0 +1,33 @@ +import type { Request, Response } from 'express' +import { getParam } from '../utils/expressParams' + +/** + * Validate that the JWT tenantId matches the URL :tenantId path param. + * Returns the tenantId on success, or null after writing an error response. + */ +export function requireMatchingTenant ( + req: Request, + res: Response +): string | null { + const tokenTenantId = (req as any).tenantId as string | undefined + const urlTenantId = getParam(req, 'tenantId') + + if (!urlTenantId) { + res.status(400).json({ error: 'Missing tenantId' }) + return null + } + + if (!tokenTenantId) { + res.status(401).json({ error: 'Unauthorized - missing tenantId in token' }) + return null + } + + if (urlTenantId !== tokenTenantId) { + res.status(403).json({ + error: 'Tenant mismatch - authenticated tenant does not match URL parameter' + }) + return null + } + + return tokenTenantId +} diff --git a/apps/opencase/src/interfaces/http/server.ts b/apps/opencase/src/interfaces/http/server.ts index 8e0628f..4aabd8b 100644 --- a/apps/opencase/src/interfaces/http/server.ts +++ b/apps/opencase/src/interfaces/http/server.ts @@ -84,6 +84,8 @@ export function createServer (container: Container): express.Express { cfPackagesController: container.controllers.management.cfPackages, tenantsController: container.controllers.management.tenants, apiKeysController: container.controllers.management.apiKeys, + membersController: container.controllers.management.members, + cgeController: container.controllers.management.cge, store: container.store, }) diff --git a/apps/opencase/src/wiring/container.ts b/apps/opencase/src/wiring/container.ts index 6207399..ccc9f5d 100644 --- a/apps/opencase/src/wiring/container.ts +++ b/apps/opencase/src/wiring/container.ts @@ -58,6 +58,8 @@ import { CFAssociationsManagementController } from '../interfaces/http/http-mana import { CFPackagesManagementController } from '../interfaces/http/http-management/controllers/CFPackagesManagementController' import { TenantsManagementController } from '../interfaces/http/http-management/controllers/TenantsManagementController' import { ApiKeysManagementController } from '../interfaces/http/http-management/controllers/ApiKeysManagementController' +import { MembersManagementController } from '../interfaces/http/http-management/controllers/MembersManagementController' +import { CgeManagementController } from '../interfaces/http/http-management/controllers/CgeManagementController' import { ListFrameworks } from '../application/case/endpoints/ListFrameworks' import { ListTenants } from '../application/case/endpoints/ListTenants' import { CreateTenant } from '../application/case/endpoints/CreateTenant' @@ -66,6 +68,9 @@ import { JsonSchemaValidator } from '../infrastructure/validation/JsonSchemaVali import { KeycloakAdminClient } from '../infrastructure/keycloak/KeycloakAdminClient' import { KeycloakTenantProvisioner } from '../infrastructure/keycloak/KeycloakTenantProvisioner' import { TenantLookupController } from '../interfaces/http/http-public/public/controllers/TenantLookupController' +import { FileCgeCredentialsStore } from '../infrastructure/cge/FileCgeCredentialsStore' +import { CgeAuthClient } from '../infrastructure/cge/CgeAuthClient' +import { CgeApiClient } from '../infrastructure/cge/CgeApiClient' export interface Container { config: AppConfig @@ -111,6 +116,8 @@ export interface Container { cfPackages: CFPackagesManagementController tenants: TenantsManagementController apiKeys: ApiKeysManagementController + members: MembersManagementController + cge: CgeManagementController } public: { tenantLookup: TenantLookupController @@ -385,6 +392,23 @@ export async function buildContainer(): Promise { clientIdPrefix: config.oidcClientIdPrefix }) + const membersManagementController = new MembersManagementController(keycloakAdmin, { + clientIdPrefix: config.oidcClientIdPrefix, + ssoOrgClaim: config.ssoOrgClaim + }) + + const cgeCredentialsStore = new FileCgeCredentialsStore( + config.caseDataDir, + config.cgeCredentialsEncryptionKey + ) + const cgeAuthClient = new CgeAuthClient(config.cgeTokenUrl) + const cgeApiClient = new CgeApiClient(config.cgeApiBaseUrl, cgeAuthClient, cgeCredentialsStore) + const cgeManagementController = new CgeManagementController( + cgeCredentialsStore, + cgeApiClient, + importFramework + ) + const tenantLookupController = new TenantLookupController(keycloakAdmin, { clientIdPrefix: config.oidcClientIdPrefix }) @@ -432,7 +456,9 @@ export async function buildContainer(): Promise { cfAssociations: cfAssociationsManagementController, cfPackages: cfPackagesManagementController, tenants: tenantsManagementController, - apiKeys: apiKeysManagementController + apiKeys: apiKeysManagementController, + members: membersManagementController, + cge: cgeManagementController }, public: { tenantLookup: tenantLookupController diff --git a/docker-compose.yml b/docker-compose.yml index bb48948..c64c358 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -124,6 +124,11 @@ services: - SMTP_PORT=${SMTP_PORT:-1025} - SMTP_FROM=noreply@${OPENCASE_HOSTNAME:-opencase.local} - KEYCLOAK_SSL_REQUIRED=${KEYCLOAK_SSL_REQUIRED:-none} + # SSO org claim + CASE Global + - SSO_ORG_CLAIM=${SSO_ORG_CLAIM:-org_id} + - CGE_TOKEN_URL=${CGE_TOKEN_URL:-} + - CGE_API_BASE_URL=${CGE_API_BASE_URL:-} + - CGE_CREDENTIALS_ENCRYPTION_KEY=${CGE_CREDENTIALS_ENCRYPTION_KEY:-} restart: on-failure volumes: - ./apps/opencase/data:/app/data diff --git a/docs/AUTH0_SSO.md b/docs/AUTH0_SSO.md index 1237003..5dba5e4 100644 --- a/docs/AUTH0_SSO.md +++ b/docs/AUTH0_SSO.md @@ -237,7 +237,31 @@ This works well for most setups. If you want to **skip the review page** and cre ## Step 7 -- Assign Roles to SSO Users -Users who log in via Auth0 are created in Keycloak with no roles by default. To give them access to OpenCASE features, you need to assign roles: +Users who log in via Auth0 are created in Keycloak with no roles by default. + +### Preferred: org → tenant + ensure-self + +OpenCASE uses **one Keycloak realm** and **one OAuth client per tenant** (`tenant-{tenantId}`). For multi-org Auth0: + +1. Create an OpenCASE tenant whose `tenantId` equals the Auth0 organization id (v1 convention: 1:1). +2. Users sign in with **Organization ID** on the Editor login screen (not a shared `DEFAULT_TENANT_ID`). +3. After login, the Editor calls `POST /management/tenants/{tenantId}/members/ensure-self`. + +**Claim contract** (configure Auth0 → Keycloak mappers separately): + +| Source | Target | Notes | +|--------|--------|-------| +| Auth0 organization id | Keycloak user attribute + access token claim `org_id` | Claim name override: env `SSO_ORG_CLAIM` | +| OpenCASE tenant client mapper | JWT `tenantId` | Hardcoded to the client’s tenant | + +`ensure-self` requires `org_id === tenantId`. If the user has no client roles yet, OpenCASE assigns default **author** roles (`case.read` + `case.write`). The user should re-authenticate so the access token includes the new scopes. + +Tenant owners can promote members via: + +``` +POST /management/tenants/{tenantId}/members +{ "email": "...", "role": "admin" | "author" | "viewer" } +``` ### Manual Role Assignment @@ -248,7 +272,7 @@ Users who log in via Auth0 are created in Keycloak with no roles by default. To ### Automatic Role Assignment (Optional) -To assign a default role to all Auth0 users automatically: +To assign a default role to all Auth0 users automatically (single-tenant only; prefer ensure-self for multi-org): 1. Go to **Identity providers** > **auth0** > **Mappers** tab 2. Click **Add mapper** diff --git a/docs/env.example b/docs/env.example index 50b69ec..8c05ce5 100644 --- a/docs/env.example +++ b/docs/env.example @@ -75,10 +75,27 @@ SMTP_PORT=1025 # Single-Tenant Mode (optional) # ============================================================================= -# Skip the tenant lookup / email screen and sign in directly with this tenant. -# Leave empty (or remove) to use the standard multi-tenant login flow. +# Skip the tenant lookup screen and sign in directly with this tenant. +# Leave empty (or remove) for multi-tenant / multi-org SSO (Organization ID or email login). +# Do NOT set this for 1EdTech multi-org Auth0 deployments — use org-id login instead. DEFAULT_TENANT_ID= +# ============================================================================= +# CASE Global (CGE) integration +# ============================================================================= + +# OAuth token endpoint for org API keys (client_credentials) +# CGE_TOKEN_URL=https://cge.example.com/realms/caseglobal/protocol/openid-connect/token + +# Coalition API base URL +# CGE_API_BASE_URL=https://cge.example.com + +# Required to store per-tenant CGE client secrets (AES-256-GCM at rest) +# CGE_CREDENTIALS_ENCRYPTION_KEY=change-me-to-a-long-random-secret + +# Access-token claim carrying Auth0 org id (must equal OpenCASE tenantId) +SSO_ORG_CLAIM=org_id + # ============================================================================= # Advanced (rarely need to change) # ============================================================================= From 8677ac2f7cf4d63634efeb2cf4e83d72c92dd6e3 Mon Sep 17 00:00:00 2001 From: tcouper Date: Thu, 16 Jul 2026 20:14:15 +0100 Subject: [PATCH 2/7] Implement CASE Global (CGE) credentials management in the Editor and OpenCASE. Adds API endpoints for retrieving, storing, and deleting CGE credentials, including support for tenant-specific API base URLs and token URLs. Updates UI components to integrate CGE credentials functionality and modifies related documentation for clarity on usage and configuration. --- .../infrastructure/caseApi/CaseApiClient.ts | 91 +++++ .../src/ui/home/CgeCredentialsDialog.tsx | 342 ++++++++++++++++++ apps/editor/src/ui/home/HomeScreen.tsx | 29 +- apps/editor/tsconfig.app.json | 1 - apps/opencase/docs/DEVELOPER.md | 46 ++- .../src/infrastructure/cge/CgeApiClient.ts | 51 ++- .../src/infrastructure/cge/CgeAuthClient.ts | 32 +- .../cge/FileCgeCredentialsStore.ts | 57 ++- .../cge/__tests__/CgeAuthClient.test.ts | 14 +- .../__tests__/FileCgeCredentialsStore.test.ts | 42 ++- .../controllers/CgeManagementController.ts | 15 +- apps/opencase/src/wiring/container.ts | 7 +- apps/opencase/tsconfig.json | 5 +- docs/env.example | 6 +- 14 files changed, 672 insertions(+), 66 deletions(-) create mode 100644 apps/editor/src/ui/home/CgeCredentialsDialog.tsx diff --git a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts index ef11b84..a14e29f 100644 --- a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts +++ b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts @@ -459,6 +459,89 @@ export class CaseApiClient { const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/members/${encodeURIComponent(params.userId)}` await this._http.delete(url) } + + // ── CASE Global (CGE) credentials ─────────────────────────────── + + /** + * Get public CGE credential status for a tenant (never returns the secret). + * Requires `case.owner` (or `case.admin`). + */ + async getCgeCredentials(params: { tenantId: string }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/cge/credentials` + const res = (await this._http.get(url)) as unknown + if (res && typeof res === 'object') { + const obj = res as CgeCredentialsPublic + return { + configured: obj.configured === true, + clientIdMasked: obj.clientIdMasked ?? null, + apiBaseUrl: obj.apiBaseUrl ?? null, + tokenUrl: obj.tokenUrl ?? null, + updatedAt: obj.updatedAt ?? null, + } + } + return { + configured: false, + clientIdMasked: null, + apiBaseUrl: null, + tokenUrl: null, + updatedAt: null, + } + } + + /** + * Store / replace CGE org API key and endpoint URLs. + * Requires `case.owner` (or `case.admin`). + * Omit `clientSecret` (or pass empty) when updating endpoints/clientId and keeping the existing secret. + */ + async putCgeCredentials(params: { + tenantId: string + clientId: string + clientSecret?: string + apiBaseUrl: string + tokenUrl: string + }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/cge/credentials` + const res = (await this._http.put(url, { + clientId: params.clientId, + clientSecret: params.clientSecret ?? '', + apiBaseUrl: params.apiBaseUrl, + tokenUrl: params.tokenUrl, + })) as unknown + if (res && typeof res === 'object') { + const obj = res as CgeCredentialsPublic + return { + configured: obj.configured === true, + clientIdMasked: obj.clientIdMasked ?? null, + apiBaseUrl: obj.apiBaseUrl ?? null, + tokenUrl: obj.tokenUrl ?? null, + updatedAt: obj.updatedAt ?? null, + } + } + throw new Error('Unexpected CGE credentials response') + } + + /** + * Delete stored CGE credentials for a tenant. + * Requires `case.owner` (or `case.admin`). + */ + async deleteCgeCredentials(params: { tenantId: string }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/cge/credentials` + await this._http.delete(url) + } + + /** + * Test CGE credentials by minting a client_credentials token. + * Requires `case.owner` (or `case.admin`). + */ + async testCgeCredentials(params: { tenantId: string }): Promise<{ ok: boolean; message: string }> { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/cge/credentials/test` + const res = (await this._http.post(url, {})) as unknown + if (res && typeof res === 'object') { + const obj = res as { ok?: boolean; message?: string } + return { ok: obj.ok === true, message: obj.message ?? '' } + } + return { ok: false, message: 'Unexpected test response' } + } } /** Summary of an API key returned by the list endpoint. */ @@ -478,3 +561,11 @@ export type TenantMember = { scopes: string[] } +export type CgeCredentialsPublic = { + configured: boolean + clientIdMasked: string | null + apiBaseUrl: string | null + tokenUrl: string | null + updatedAt: string | null +} + diff --git a/apps/editor/src/ui/home/CgeCredentialsDialog.tsx b/apps/editor/src/ui/home/CgeCredentialsDialog.tsx new file mode 100644 index 0000000..9cd695e --- /dev/null +++ b/apps/editor/src/ui/home/CgeCredentialsDialog.tsx @@ -0,0 +1,342 @@ +import { useCallback, useEffect, useState } from 'react' +import { Button } from '@/ui/shared/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/ui/shared/components/ui/dialog' +import { Input } from '@/ui/shared/components/ui/input' +import { Label } from '@/ui/shared/components/ui/label' +import type { CaseApiClient, CgeCredentialsPublic } from '@/infrastructure/caseApi/CaseApiClient' + +export default function CgeCredentialsDialog({ + open, + onClose, + api, + tenantId, +}: Readonly<{ + open: boolean + onClose: () => void + api: CaseApiClient + tenantId: string +}>) { + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + const [apiBaseUrl, setApiBaseUrl] = useState('') + const [tokenUrl, setTokenUrl] = useState('') + const [clientId, setClientId] = useState('') + const [clientSecret, setClientSecret] = useState('') + const [saving, setSaving] = useState(false) + const [testing, setTesting] = useState(false) + const [deleting, setDeleting] = useState(false) + const [confirmDelete, setConfirmDelete] = useState(false) + + const loadStatus = useCallback(async () => { + setLoading(true) + setError(null) + try { + const result = await api.getCgeCredentials({ tenantId }) + setStatus(result) + if (result.configured) { + setApiBaseUrl(result.apiBaseUrl ?? '') + setTokenUrl(result.tokenUrl ?? '') + } + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + }, [api, tenantId]) + + useEffect(() => { + if (open) { + setClientId('') + setClientSecret('') + setApiBaseUrl('') + setTokenUrl('') + setSuccess(null) + setError(null) + setConfirmDelete(false) + void loadStatus() + } + }, [open, loadStatus]) + + const handleSave = useCallback(async () => { + const id = clientId.trim() + const base = apiBaseUrl.trim() + const token = tokenUrl.trim() + const secret = clientSecret + if (!base || !token) { + setError('API base URL and token URL are required') + return + } + if (!id) { + setError('Client ID is required') + return + } + if (!status?.configured && !secret.trim()) { + setError('Client secret is required') + return + } + setSaving(true) + setError(null) + setSuccess(null) + try { + const result = await api.putCgeCredentials({ + tenantId, + clientId: id, + clientSecret: secret, + apiBaseUrl: base, + tokenUrl: token, + }) + setStatus(result) + setClientSecret('') + setApiBaseUrl(result.apiBaseUrl ?? base) + setTokenUrl(result.tokenUrl ?? token) + setSuccess('CASE Global connection saved.') + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setSaving(false) + } + }, [api, tenantId, clientId, clientSecret, apiBaseUrl, tokenUrl, status?.configured]) + + const handleTest = useCallback(async () => { + setTesting(true) + setError(null) + setSuccess(null) + try { + const result = await api.testCgeCredentials({ tenantId }) + if (result.ok) { + setSuccess(result.message || 'Token minted successfully.') + } else { + setError(result.message || 'Credential test failed.') + } + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setTesting(false) + } + }, [api, tenantId]) + + const handleDelete = useCallback(async () => { + setDeleting(true) + setError(null) + setSuccess(null) + try { + await api.deleteCgeCredentials({ tenantId }) + setStatus({ + configured: false, + clientIdMasked: null, + apiBaseUrl: null, + tokenUrl: null, + updatedAt: null, + }) + setApiBaseUrl('') + setTokenUrl('') + setClientId('') + setClientSecret('') + setConfirmDelete(false) + setSuccess('CASE Global connection removed.') + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setDeleting(false) + } + }, [api, tenantId]) + + const handleClose = useCallback(() => { + setConfirmDelete(false) + setError(null) + setSuccess(null) + setClientId('') + setClientSecret('') + setApiBaseUrl('') + setTokenUrl('') + onClose() + }, [onClose]) + + const canSave = + Boolean(apiBaseUrl.trim() && tokenUrl.trim() && clientId.trim()) && + (status?.configured || Boolean(clientSecret.trim())) + + return ( + <> + { if (!v) handleClose() }}> + + + CASE Global + + Configure this organization's CASE Global endpoint and consumer credentials. + The client secret is kept on the OpenCASE server only and never returned after save. + + + + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + +
+ {loading ? ( +

Loading...

+ ) : status?.configured ? ( +
+

Configured

+

+ Client ID: {status.clientIdMasked} +

+ {status.apiBaseUrl ? ( +

+ API: {status.apiBaseUrl} +

+ ) : null} + {status.updatedAt ? ( +

+ Updated {new Date(status.updatedAt).toLocaleString()} +

+ ) : null} +
+ ) : ( +

No CASE Global connection configured yet.

+ )} +
+ +
+
+
+ + setApiBaseUrl(e.target.value)} + placeholder="https://cge.example.com" + className="font-mono text-sm" + autoComplete="off" + /> +
+
+ + setTokenUrl(e.target.value)} + placeholder="https://cge.example.com/.../token" + className="font-mono text-sm" + autoComplete="off" + /> +
+
+ + setClientId(e.target.value)} + placeholder={status?.configured ? (status.clientIdMasked ?? 'Client ID') : 'Organisation API key client_id'} + className="font-mono text-sm" + autoComplete="off" + /> +
+
+ + setClientSecret(e.target.value)} + placeholder={ + status?.configured + ? 'Leave blank to keep current secret' + : 'Organisation API key client_secret' + } + className="font-mono text-sm" + autoComplete="new-password" + /> +
+
+

+ {status?.configured + ? 'Saving updates endpoints and credentials. Leave the secret blank to keep the current one.' + : 'Saving stores the endpoint and credentials for this organization.'} +

+
+ + {status?.configured ? ( + + ) : null} + {status?.configured ? ( + + ) : null} +
+
+ + + + +
+
+ + { if (!v) setConfirmDelete(false) }}> + + + Remove CASE Global connection + + Remove the stored CASE Global endpoint and API key for this organization? + Search and import from CASE Global will stop working until a new connection is saved. + + + + + + + + + + ) +} diff --git a/apps/editor/src/ui/home/HomeScreen.tsx b/apps/editor/src/ui/home/HomeScreen.tsx index e70a57d..cd3e302 100644 --- a/apps/editor/src/ui/home/HomeScreen.tsx +++ b/apps/editor/src/ui/home/HomeScreen.tsx @@ -1,4 +1,4 @@ -import { PlusIcon, ArrowPathIcon, MagnifyingGlassIcon, FunnelIcon, XMarkIcon, ArrowRightStartOnRectangleIcon, CloudArrowDownIcon, KeyIcon, ArrowUpTrayIcon, UsersIcon } from '@heroicons/react/24/solid' +import { PlusIcon, ArrowPathIcon, MagnifyingGlassIcon, FunnelIcon, XMarkIcon, ArrowRightStartOnRectangleIcon, CloudArrowDownIcon, KeyIcon, ArrowUpTrayIcon, UsersIcon, GlobeAltIcon } from '@heroicons/react/24/solid' import { CodeBracketSquareIcon } from '@heroicons/react/24/outline' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/ui/shared/components/ui/button' @@ -9,6 +9,7 @@ import ImportFrameworkDialog from '@/ui/home/ImportFrameworkDialog' import UploadFrameworkDialog from '@/ui/home/UploadFrameworkDialog' import ApiKeysDialog from '@/ui/home/ApiKeysDialog' import MembersDialog from '@/ui/home/MembersDialog' +import CgeCredentialsDialog from '@/ui/home/CgeCredentialsDialog' import type { Framework } from '@/domain/framework/model/types' import { useAuth } from '@/app/providers/AuthProvider' import { getAppConfig } from '@/app/config' @@ -43,6 +44,7 @@ function UserAvatarMenu({ onChangePassword, onApiKeys, onMembers, + onCgeCredentials, }: Readonly<{ userName?: string tenantId?: string @@ -51,6 +53,7 @@ function UserAvatarMenu({ onChangePassword?: () => void onApiKeys?: () => void onMembers?: () => void + onCgeCredentials?: () => void }>) { const [open, setOpen] = useState(false) const rootRef = useRef(null) @@ -79,7 +82,7 @@ function UserAvatarMenu({ {open ? ( -
+
{userName ? (
Signed in as {userName}
@@ -120,6 +123,17 @@ function UserAvatarMenu({ API Keys ) : null} + {isAuthenticated && onCgeCredentials ? ( + + ) : null} {isAuthenticated && onSignOut ? ( + ))} +
+ +
+
+ ) +} diff --git a/apps/editor/src/ui/editor/components/CgeFrameworkSearchPanel.tsx b/apps/editor/src/ui/editor/components/CgeFrameworkSearchPanel.tsx new file mode 100644 index 0000000..be62f87 --- /dev/null +++ b/apps/editor/src/ui/editor/components/CgeFrameworkSearchPanel.tsx @@ -0,0 +1,153 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Button } from '@/ui/shared/components/ui/button' +import { Input } from '@/ui/shared/components/ui/input' +import type { CaseApiClient } from '@/infrastructure/caseApi/CaseApiClient' +import { normalizeCgeFrameworkList, normalizeCgeSubscriptionList } from '@/infrastructure/caseApi/CaseApiClient' +import type { CgeFrameworkSummary } from '@/infrastructure/caseApi/cgeTypes' +import { formatApiErrorMessage } from '@/infrastructure/caseApi/http' + +type Props = { + tenantId: string + api: CaseApiClient + onClose: () => void + onAddToCanvas: (framework: CgeFrameworkSummary) => Promise +} + +export default function CgeFrameworkSearchPanel({ tenantId, api, onClose, onAddToCanvas }: Readonly) { + const [query, setQuery] = useState('') + const [debouncedQuery, setDebouncedQuery] = useState('') + const [frameworks, setFrameworks] = useState([]) + const [subscribedIds, setSubscribedIds] = useState>(new Set()) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [busyId, setBusyId] = useState(null) + const [addingId, setAddingId] = useState(null) + + useEffect(() => { + const t = globalThis.setTimeout(() => setDebouncedQuery(query.trim()), 300) + return () => globalThis.clearTimeout(t) + }, [query]) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const [fwRes, subRes] = await Promise.all([ + api.listCgeFrameworks({ tenantId, search: debouncedQuery || undefined, limit: 30 }), + api.listCgeSubscriptions({ tenantId }).catch(() => null), + ]) + const list = normalizeCgeFrameworkList(fwRes) + const subs = normalizeCgeSubscriptionList(subRes) + const subSet = new Set(subs.map((s) => s.frameworkId)) + for (const f of list) { + if (f.subscribed) subSet.add(f.frameworkId) + } + setFrameworks(list) + setSubscribedIds(subSet) + } catch (e) { + setError(formatApiErrorMessage(e, 'Search failed')) + setFrameworks([]) + } finally { + setLoading(false) + } + }, [api, tenantId, debouncedQuery]) + + useEffect(() => { + void load() + }, [load]) + + const handleSubscribe = async (frameworkId: string) => { + setBusyId(frameworkId) + setError(null) + try { + await api.createCgeSubscription({ tenantId, frameworkId }) + setSubscribedIds((prev) => new Set(prev).add(frameworkId)) + } catch (e) { + setError(formatApiErrorMessage(e, 'Subscribe failed')) + } finally { + setBusyId(null) + } + } + + const handleAdd = async (fw: CgeFrameworkSummary) => { + setAddingId(fw.frameworkId) + setError(null) + try { + await onAddToCanvas(fw) + } catch (e) { + setError(formatApiErrorMessage(e, 'Add to canvas failed')) + } finally { + setAddingId(null) + } + } + + const rows = useMemo(() => frameworks, [frameworks]) + + return ( +
+
+
+
+

Add remote framework

+

Search CASE Global registered frameworks

+
+ +
+ setQuery(e.target.value)} + placeholder="Search frameworks…" + className="mt-3" + autoFocus + /> +
+ +
+ {error ? ( +

{error}

+ ) : null} + {loading ? ( +

Searching…

+ ) : rows.length === 0 ? ( +

No frameworks found.

+ ) : ( +
    + {rows.map((fw) => { + const subscribed = subscribedIds.has(fw.frameworkId) + return ( +
  • +
    {fw.title}
    + {fw.publisher ?
    {fw.publisher}
    : null} + {fw.version ?
    v{fw.version}
    : null} +
    + {subscribed ? ( + Subscribed + ) : ( + + )} + +
    +
  • + ) + })} +
+ )} +
+
+ ) +} diff --git a/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx b/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx index 763ee53..73c6388 100644 --- a/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx +++ b/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx @@ -1,5 +1,8 @@ import { memo, useEffect, useMemo, useState } from 'react' +import type { CheckedState } from '@radix-ui/react-checkbox' import { Button } from '@/ui/shared/components/ui/button' +import { Checkbox } from '@/ui/shared/components/ui/checkbox' +import { Label } from '@/ui/shared/components/ui/label' import { ComboboxInput } from '@/ui/shared/components/ui/combobox-input' import { TagComboboxInput } from '@/ui/shared/components/ui/tag-combobox-input' import type { TagComboboxOption } from '@/ui/shared/components/ui/tag-combobox-input' @@ -19,6 +22,9 @@ import type { EducationLevelOption } from '@/ui/editor/terminology/educationLeve import { getAppConfig } from '@/app/config' import ColorBandPicker from '@/ui/editor/components/ColorBandPicker' import SidebarSection from './SidebarSection' +import type { RemoteItemLink } from '@/ui/editor/remoteFramework/remoteFrameworkTypes' +import { CASE_ASSOCIATION_TYPES } from '../reactflow/types' +import { ArrowPathIcon } from '@heroicons/react/24/solid' /* ── Shared styling constants ── */ const INPUT_CLS = 'w-full rounded-xl border border-black/15 bg-white px-3 py-2.5 text-base text-slate-900 focus-visible:outline-2 focus-visible:outline-violet-700/40 focus-visible:outline-offset-2' @@ -38,11 +44,20 @@ type Props = { ensureCfSubject?: (_title: string) => CFSubject | null cfConcepts?: CFConcept[] ensureCfConcept?: (_title: string) => CFConcept | null + remoteLinks?: RemoteItemLink[] + onRemoveRemoteLink?: (_linkId: string) => void + onUpdateRemoteLinkType?: (_linkId: string, _associationType: string) => void + onRemoveRemoteFramework?: (_nodeId: string) => void + onBrowseRemoteItems?: (_nodeId: string) => void + onRefreshRemoteFramework?: () => Promise + remoteFrameworkRefreshing?: boolean } export default memo(function NodePropertiesPanel({ node, onClose, onChangeNode, onViewCFPackage, isPublishedToOpenCase, availableLicenses, cfItemTypes = [], ensureCfItemType, cfSubjects = [], ensureCfSubject, cfConcepts = [], ensureCfConcept, + remoteLinks = [], onRemoveRemoteLink, onUpdateRemoteLinkType, + onRemoveRemoteFramework, onBrowseRemoteItems, onRefreshRemoteFramework, remoteFrameworkRefreshing, }: Readonly) { const [copied, setCopied] = useState(null) const [conceptInput, setConceptInput] = useState('') @@ -144,11 +159,21 @@ export default memo(function NodePropertiesPanel({ : (cfItem?.humanCodingScheme ?? cfItem?.alternativeLabel ?? cfItem?.CFItemType ?? 'Untitled item') const headerSubtitle = isExternalFramework - ? 'External reference' + ? (externalData?.cacheError + ? 'Cache unavailable' + : externalData?.cacheDocId + ? 'Cached remote framework' + : externalData?.cacheLoading + ? 'Downloading cache…' + : 'Remote framework (not cached)') : isFramework ? 'Framework' : (cfItem?.CFItemType ?? 'Item') + const itemRemoteLinks = node && isItemNode(node) + ? remoteLinks.filter((l) => l.localItemId === node.id) + : [] + return (