Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions apps/editor/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { getAppConfig } from '@/app/config'
import { CaseApiClient, type CfDocumentSummary } from '@/infrastructure/caseApi/CaseApiClient'
import { createFetchHttpClient } from '@/infrastructure/caseApi/http'
import { loadFrameworkFromCfPackage } from '@/application/framework/services/FrameworkLoader'
import { toReactFlowGraph, extractLayoutFromCfPackage, extractEditorSettingsFromCfPackage } from '@/ui/editor/reactflow/mapping'
import { toReactFlowGraph, extractLayoutFromCfPackage, extractEditorSettingsFromCfPackage, extractRemoteFrameworkDataFromCfPackage, normalizeLinkedFrameworkColors } from '@/ui/editor/reactflow/mapping'
import type { LayoutState } from '@/ui/editor/reactflow/mapping'
import type { CaseVersion } from '@/application/framework/mappers/case/CasePackageSnapshot'
import type { CFAssociationGrouping, CFItemType, CFLicense, CFSubject, CFConcept } from '@/domain/case/types'
Expand Down Expand Up @@ -204,6 +204,19 @@ function AppInner() {
setRoute('login')
}, [authStatus, route])

// SSO: ensure default tenant membership when org_id claim matches (idempotent).
const ensureSelfAttempted = useRef<string | null>(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
Expand Down Expand Up @@ -381,6 +394,9 @@ function AppInner() {

// Create a HomeFramework entry from the domain Framework
const fw = createHomeFrameworkFromDomain(framework, mirrorStatus)
if (pkg.CFDocument?.extensions) {
fw.cfDocument = { ...fw.cfDocument, extensions: pkg.CFDocument.extensions }
}

// Store the extracted layout
if (layout) {
Expand Down Expand Up @@ -473,7 +489,13 @@ function AppInner() {

// Get the stored layout for this framework (from CASE extensions)
const layout = frameworkLayouts[activeFramework.id]
const graph = toReactFlowGraph({ framework: activeFramework.framework, layout })
const remoteEditorData = extractRemoteFrameworkDataFromCfPackage({
CFDocument: activeFramework.cfDocument,
CFItems: [],
CFAssociations: [],
})
remoteEditorData.linkedFrameworks = normalizeLinkedFrameworkColors(remoteEditorData.linkedFrameworks)
const graph = toReactFlowGraph({ framework: activeFramework.framework, layout, remoteEditorData })

// If no saved layout, detect topology and apply appropriate layout
if (!layout) {
Expand Down
64 changes: 44 additions & 20 deletions apps/editor/src/application/framework/mappers/case/toCasePackage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,12 @@ function makePackageUri(uuid: string): string {
type OpencaseExtension = {
layout?: NodeLayout
notes?: string
/** Persisted handle ID on the origin (from) node — preserves user-defined edge anchors */
originHandle?: string
/** Persisted handle ID on the destination (to) node — preserves user-defined edge anchors */
destinationHandle?: string
/** Edge rendering style for this framework (e.g. 'default', 'smoothstep', 'straight') */
edgeType?: string
/** Visual color band hex color for item nodes */
colorBand?: string
linkedFrameworks?: unknown[]
remoteItemLinks?: unknown[]
}

/**
Expand All @@ -103,7 +101,7 @@ function mergeOpencaseExtension(
const extensions = { ...base }

// Only add if there's data to store
if (opencaseData.layout || opencaseData.notes || opencaseData.originHandle || opencaseData.destinationHandle || opencaseData.edgeType || opencaseData.colorBand) {
if (opencaseData.layout || opencaseData.notes || opencaseData.originHandle || opencaseData.destinationHandle || opencaseData.edgeType || opencaseData.colorBand || opencaseData.linkedFrameworks || opencaseData.remoteItemLinks) {
const existing = (extensions[OPENCASE_EXT_KEY] as OpencaseExtension | undefined) ?? {}
extensions[OPENCASE_EXT_KEY] = {
...existing,
Expand All @@ -121,7 +119,7 @@ function frameworkToCfDocument(
framework: Framework,
caseVersion: CaseVersion,
layout?: NodeLayout,
options?: { edgeType?: string }
options?: { edgeType?: string; linkedFrameworks?: unknown[]; remoteItemLinks?: unknown[] }
): CFDocument {
const meta = framework.metadata
const fwId = String(framework.id)
Expand Down Expand Up @@ -156,8 +154,13 @@ function frameworkToCfDocument(
title: docTitle,
identifier: fwId,
},
extensions: (layout || options?.edgeType)
? mergeOpencaseExtension(undefined, { layout, edgeType: options?.edgeType })
extensions: (layout || options?.edgeType || options?.linkedFrameworks || options?.remoteItemLinks)
? mergeOpencaseExtension(undefined, {
layout,
edgeType: options?.edgeType,
linkedFrameworks: options?.linkedFrameworks,
remoteItemLinks: options?.remoteItemLinks,
})
: undefined,
}

Expand Down Expand Up @@ -264,6 +267,7 @@ function associationToCfAssociation(
}

const existingExtensions = (md.extensions as CaseExtensions | undefined) ?? undefined
const remoteExt = (existingExtensions?.[OPENCASE_EXT_KEY] as { remoteLink?: boolean; remoteItemUri?: string; remoteItemIdentifier?: string; remoteLabel?: string; localItemUri?: string } | undefined)

// Persist user-defined edge handle positions in ext:opencase
const originHandle = s('originHandle')
Expand All @@ -272,21 +276,35 @@ function associationToCfAssociation(
? mergeOpencaseExtension(existingExtensions, { originHandle, destinationHandle })
: existingExtensions

const isRemoteLink = remoteExt?.remoteLink === true

const cfAssociation: CFAssociation & { sourcedId: string } = {
identifier: assocId,
sourcedId: assocId, // OpenCASE requires sourcedId
uri: s('caseUri') ?? `urn:case:association:${assocId}`,
associationType: assoc.associationType,
originNodeURI: {
identifier: fromId,
uri: s('originUri') ?? `urn:case:item:${fromId}`,
title: fromTitle,
},
destinationNodeURI: {
identifier: toId,
uri: s('destinationUri') ?? `urn:case:item:${toId}`,
title: toTitle,
},
originNodeURI: isRemoteLink
? {
identifier: fromId,
uri: remoteExt?.localItemUri ?? s('originUri') ?? `urn:case:item:${fromId}`,
title: fromTitle,
}
: {
identifier: fromId,
uri: s('originUri') ?? `urn:case:item:${fromId}`,
title: fromTitle,
},
destinationNodeURI: isRemoteLink
? {
identifier: remoteExt?.remoteItemIdentifier ?? toId,
uri: remoteExt?.remoteItemUri ?? s('destinationUri') ?? `urn:case:item:${toId}`,
title: remoteExt?.remoteLabel ?? toTitle,
}
: {
identifier: toId,
uri: s('destinationUri') ?? `urn:case:item:${toId}`,
title: toTitle,
},
sequenceNumber: n('sequenceNumber'),
CFAssociationGroupingURI: s('CFAssociationGroupingIdentifier')
? {
Expand Down Expand Up @@ -334,13 +352,19 @@ export function frameworkToCfPackage(params: {
cfAssociationGroupings?: CFAssociationGrouping[]
/** CFLicense definitions to include in CFDefinitions (from editor state) */
cfLicenses?: CFLicense[]
/** Remote framework editor data from canvas */
remoteEditorData?: { linkedFrameworks: unknown[]; remoteItemLinks: unknown[] }
}): CFPackage {
const { framework, caseVersion, layout, edgeType, cfItemTypes, cfSubjects, cfConcepts, cfAssociationGroupings, cfLicenses } = params
const { framework, caseVersion, layout, edgeType, cfItemTypes, cfSubjects, cfConcepts, cfAssociationGroupings, cfLicenses, remoteEditorData } = params
const fwId = String(framework.id)

// Build CFDocument
const documentLayout = layout?.byNodeId?.[fwId]
const document = frameworkToCfDocument(framework, caseVersion, documentLayout, { edgeType })
const document = frameworkToCfDocument(framework, caseVersion, documentLayout, {
edgeType,
linkedFrameworks: remoteEditorData?.linkedFrameworks,
remoteItemLinks: remoteEditorData?.remoteItemLinks,
})

// Build CFItems
const itemIds = Array.from(framework.items.keys()).map(String)
Expand Down
36 changes: 36 additions & 0 deletions apps/editor/src/infrastructure/auth/tokenScopes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { tokenHasCaseOwner } from './tokenScopes'

function makeToken (payload: Record<string, unknown>): 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)
})
})
71 changes: 71 additions & 0 deletions apps/editor/src/infrastructure/auth/tokenScopes.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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<string, unknown>
} 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<string, unknown>): Set<string> {
const scopes = new Set<string>()
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<string, { roles?: unknown }>)) {
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')
}
Loading
Loading