diff --git a/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicy.php b/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicy.php index e46c3a1c2a..948cd38c9e 100644 --- a/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicy.php +++ b/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicy.php @@ -13,6 +13,7 @@ use OCA\Libresign\Service\Policy\Model\PolicyContext; use OCA\Libresign\Service\Policy\Model\PolicyLayer; use OCA\Libresign\Service\Policy\Model\PolicySpec; +use OCA\Libresign\Service\Policy\Provider\CollectMetadata\CollectMetadataPolicy; use OCA\Libresign\Service\Policy\Provider\Helper\DelegationLayerHelper; use OCA\Libresign\Service\Policy\Provider\Helper\PolicyKeyNormalizer; use OCA\Libresign\Service\SignatureTextTemplate; @@ -51,6 +52,7 @@ public function get(string|\BackedEnum $policyKey): IPolicyDefinition { appConfigKey: self::SYSTEM_APP_CONFIG_KEY, supportsRequestOverride: true, requestLifecycle: PolicySpec::LIFECYCLE_RUNTIME, + compositeChildren: [CollectMetadataPolicy::KEY], groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { $actorRole = $context->getActorRole(); if ($actorRole->canManageSystemPolicies) { diff --git a/playwright/e2e/policy-workbench-signature-stamp-rule-management.spec.ts b/playwright/e2e/policy-workbench-signature-stamp-rule-management.spec.ts index fed08c18bf..31038b22e9 100644 --- a/playwright/e2e/policy-workbench-signature-stamp-rule-management.spec.ts +++ b/playwright/e2e/policy-workbench-signature-stamp-rule-management.spec.ts @@ -10,6 +10,7 @@ import { expect, test } from '@playwright/test' import { bootstrapLibreSignAdmin, ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' import { login } from '../support/nc-login' +import { createAuthenticatedRequestContext, getEffectivePolicy } from '../support/policy-api' import { openPolicyWorkbenchSystemRuleEditor, waitForPolicyWorkbenchIdle } from '../support/policy-workbench-rules' const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' @@ -62,13 +63,19 @@ test.describe('P06: signature_stamp persists a system rule from the workbench UI await expect(saveButton).toBeEnabled({ timeout: 10000 }) const [response] = await Promise.all([ page.waitForResponse((response) => { - return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) - && response.url().includes('/apps/libresign/api/v1/policies/system/signature_stamp') + return response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/policies/compound/system/signature_stamp') }), saveButton.click(), ]) expect(response.status()).toBe(200) + const savedValues = (response.request().postDataJSON() as { values?: Record }).values ?? {} + expect(Object.keys(savedValues).sort()).toEqual(['collect_metadata', 'signature_stamp']) + expect(typeof savedValues.signature_stamp).toBe('string') + expect(typeof savedValues.collect_metadata).toBe('boolean') + const savedRenderMode = JSON.parse(savedValues.signature_stamp as string).render_mode + // Wait for save confirmation (network idle or success toast) await waitForPolicyWorkbenchIdle(page) await page.waitForTimeout(1000) @@ -90,5 +97,19 @@ test.describe('P06: signature_stamp persists a system rule from the workbench UI // Verify that the dialog opens (indicating persistence was successful) const dialogReopen = page.getByRole('dialog').filter({ hasText: /Signature stamp text/i }).first() await expect(dialogReopen).toBeVisible({ timeout: 10000 }) + + // Both compound members keep the values that were sent in the compound write + const adminRequest = await createAuthenticatedRequestContext(adminUser, adminPassword) + try { + const [signatureStamp, collectMetadata] = await Promise.all([ + getEffectivePolicy(adminRequest, 'signature_stamp'), + getEffectivePolicy(adminRequest, 'collect_metadata'), + ]) + + expect(JSON.parse(String(signatureStamp?.effectiveValue)).render_mode).toBe(savedRenderMode) + expect(collectMetadata?.effectiveValue).toBe(savedValues.collect_metadata) + } finally { + await adminRequest.dispose() + } }) }) diff --git a/src/tests/views/Settings/PolicyWorkbench/policyWorkbenchCompoundPolicies.spec.ts b/src/tests/views/Settings/PolicyWorkbench/policyWorkbenchCompoundPolicies.spec.ts index ebb0476f1a..003d89c776 100644 --- a/src/tests/views/Settings/PolicyWorkbench/policyWorkbenchCompoundPolicies.spec.ts +++ b/src/tests/views/Settings/PolicyWorkbench/policyWorkbenchCompoundPolicies.spec.ts @@ -5,6 +5,8 @@ import { describe, expect, it, vi } from 'vitest' +import type { EffectivePolicyValue } from '../../../../types/index' + import { clearCompoundPolicyTarget, clearCompoundUserPreferences, @@ -16,9 +18,13 @@ import { SIGNATURE_STAMP_POLICY_KEY, SIGNING_EXECUTION_POLICY_KEY, SIGNING_EXECUTION_WORKER_KEY, + type CompoundPolicyMemberRecords, type PersistedSystemPolicyRecord, type PolicyRuleRecord, } from '../../../../views/Settings/PolicyWorkbench/policyWorkbenchCompoundPolicies' +import { maximumValidityRealDefinition } from '../../../../views/Settings/PolicyWorkbench/settings/expiration-rules/realDefinitions' +import type { RealPolicyCompoundBehavior } from '../../../../views/Settings/PolicyWorkbench/settings/realTypes' +import { signatureTextRealDefinition } from '../../../../views/Settings/PolicyWorkbench/settings/signature-text/realDefinition' const createPolicyRule = (overrides: Partial = {}): PolicyRuleRecord => ({ id: 'rule-1', @@ -36,11 +42,27 @@ const createPersistedSystemPolicy = (overrides: Partial = {}): CompoundPolicyMemberRecords => ({ + policyKey, + systemPolicy: null, + groupPolicies: [], + userPolicies: [], + ...overrides, +}) + +const requireCompound = (compound: RealPolicyCompoundBehavior | undefined): RealPolicyCompoundBehavior => { + if (!compound) { + throw new Error('Expected the policy definition to declare a compound mapping') + } + + return compound +} + describe('policyWorkbenchCompoundPolicies', () => { const signatureStampValue = '{"template":"Signed by {{SignerCommonName}}","template_font_size":9.8,"signature_font_size":9.8,"signature_width":350,"signature_height":100,"background_type":"default","render_mode":"default"}' - it('saves request expiration system rules across both persisted policy keys', async () => { - const saveSystemPolicy = vi.fn().mockResolvedValue(undefined) + it('saves request expiration system rules through the compound store method', async () => { + const saveSystemPolicyCompound = vi.fn().mockResolvedValue(null) const result = await saveCompoundPolicyValue({ scope: 'system', @@ -52,10 +74,12 @@ describe('policyWorkbenchCompoundPolicies', () => { targetIds: [], allowChildOverride: false, policiesStore: { - saveSystemPolicy, - saveGroupPolicy: vi.fn(), - saveUserPolicyForUser: vi.fn(), + saveSystemPolicyCompound, + saveGroupPolicyCompound: vi.fn(), + saveUserPolicyForUserCompound: vi.fn(), }, + compound: maximumValidityRealDefinition.compound, + compositeChildren: [REQUEST_EXPIRATION_RENEWAL_KEY], }) expect(result).toEqual({ @@ -65,26 +89,29 @@ describe('policyWorkbenchCompoundPolicies', () => { renewalInterval: 4, }, }) - expect(saveSystemPolicy).toHaveBeenCalledTimes(2) - expect(saveSystemPolicy).toHaveBeenNthCalledWith(1, REQUEST_EXPIRATION_POLICY_KEY, 15, false) - expect(saveSystemPolicy).toHaveBeenNthCalledWith(2, REQUEST_EXPIRATION_RENEWAL_KEY, 4, false) + expect(saveSystemPolicyCompound).toHaveBeenCalledTimes(1) + expect(saveSystemPolicyCompound).toHaveBeenCalledWith( + REQUEST_EXPIRATION_POLICY_KEY, + { [REQUEST_EXPIRATION_POLICY_KEY]: 15, [REQUEST_EXPIRATION_RENEWAL_KEY]: 4 }, + { [REQUEST_EXPIRATION_POLICY_KEY]: false, [REQUEST_EXPIRATION_RENEWAL_KEY]: false }, + ) }) it('hydrates request expiration group rules even when only the renewal companion rule exists', () => { const result = hydrateCompoundPolicyRules({ - policyKey: REQUEST_EXPIRATION_POLICY_KEY, - persistedSystemPolicy: null, - companionSystemPolicy: null, - persistedGroupPolicies: [], - companionGroupPolicies: [ - createPolicyRule({ - id: 'renewal-finance', - value: 7, - canRemove: false, + parent: createMemberRecords(REQUEST_EXPIRATION_POLICY_KEY), + children: [ + createMemberRecords(REQUEST_EXPIRATION_RENEWAL_KEY, { + groupPolicies: [ + createPolicyRule({ + id: 'renewal-finance', + value: 7, + canRemove: false, + }), + ], }), ], - persistedUserPolicies: [], - companionUserPolicies: [], + compound: requireCompound(maximumValidityRealDefinition.compound), }) expect(result).toEqual({ @@ -108,25 +135,29 @@ describe('policyWorkbenchCompoundPolicies', () => { it('does not create signature stamp rules from collect_metadata-only overrides', () => { const result = hydrateCompoundPolicyRules({ - policyKey: SIGNATURE_STAMP_POLICY_KEY, - persistedSystemPolicy: createPersistedSystemPolicy({ value: null }), - companionSystemPolicy: createPersistedSystemPolicy({ value: true }), - persistedGroupPolicies: [], - companionGroupPolicies: [ - createPolicyRule({ - id: 'collect-finance', - value: true, - }), - ], - persistedUserPolicies: [], - companionUserPolicies: [ - createPolicyRule({ - scope: 'user', - id: 'collect-user1', - targetId: 'user1', - value: true, + parent: createMemberRecords(SIGNATURE_STAMP_POLICY_KEY, { + systemPolicy: createPersistedSystemPolicy({ value: null }), + }), + children: [ + createMemberRecords(COLLECT_METADATA_POLICY_KEY, { + systemPolicy: createPersistedSystemPolicy({ value: true }), + groupPolicies: [ + createPolicyRule({ + id: 'collect-finance', + value: true, + }), + ], + userPolicies: [ + createPolicyRule({ + scope: 'user', + id: 'collect-user1', + targetId: 'user1', + value: true, + }), + ], }), ], + compound: requireCompound(signatureTextRealDefinition.compound), }) expect(result).toEqual({ @@ -138,56 +169,200 @@ describe('policyWorkbenchCompoundPolicies', () => { it('hydrates signature stamp group rules with collect metadata companion values', () => { const result = hydrateCompoundPolicyRules({ - policyKey: SIGNATURE_STAMP_POLICY_KEY, - persistedSystemPolicy: null, - companionSystemPolicy: null, - persistedGroupPolicies: [ - createPolicyRule({ - id: 'signature-finance', - value: signatureStampValue, - }), - ], - companionGroupPolicies: [ - createPolicyRule({ - id: 'collect-finance', - value: true, + parent: createMemberRecords(SIGNATURE_STAMP_POLICY_KEY, { + groupPolicies: [ + createPolicyRule({ + id: 'signature-finance', + value: signatureStampValue, + }), + ], + }), + children: [ + createMemberRecords(COLLECT_METADATA_POLICY_KEY, { + groupPolicies: [ + createPolicyRule({ + id: 'collect-finance', + value: true, + }), + ], }), ], - persistedUserPolicies: [], - companionUserPolicies: [], + compound: requireCompound(signatureTextRealDefinition.compound), }) - expect(result?.groupRules[0]?.value).toEqual({ + expect(result.groupRules[0]?.value).toEqual({ signatureStampValue, collectMetadataEnabled: true, }) }) - it('clears signature stamp user preferences across both persisted policy keys', async () => { + it('clears signature stamp user preferences for every compound member', async () => { const clearUserPreference = vi.fn().mockResolvedValue(undefined) - const handled = await clearCompoundUserPreferences(SIGNATURE_STAMP_POLICY_KEY, { + await clearCompoundUserPreferences([SIGNATURE_STAMP_POLICY_KEY, COLLECT_METADATA_POLICY_KEY], { clearUserPreference, }) - expect(handled).toBe(true) expect(clearUserPreference).toHaveBeenCalledTimes(2) expect(clearUserPreference).toHaveBeenNthCalledWith(1, SIGNATURE_STAMP_POLICY_KEY) expect(clearUserPreference).toHaveBeenNthCalledWith(2, COLLECT_METADATA_POLICY_KEY) }) - it('clears signing execution group rules across both persisted policy keys', async () => { + it('clears signing execution group rules for every compound member', async () => { const clearGroupPolicy = vi.fn().mockResolvedValue(undefined) - const handled = await clearCompoundPolicyTarget('group', SIGNING_EXECUTION_POLICY_KEY, 'finance', { + await clearCompoundPolicyTarget('group', [SIGNING_EXECUTION_POLICY_KEY, SIGNING_EXECUTION_WORKER_KEY], 'finance', { clearGroupPolicy, clearUserPolicyForUser: vi.fn(), saveSystemPolicy: vi.fn(), }) - expect(handled).toBe(true) expect(clearGroupPolicy).toHaveBeenCalledTimes(2) expect(clearGroupPolicy).toHaveBeenNthCalledWith(1, 'finance', SIGNING_EXECUTION_POLICY_KEY) expect(clearGroupPolicy).toHaveBeenNthCalledWith(2, 'finance', SIGNING_EXECUTION_WORKER_KEY) }) + + describe('with a parent policy and two child policies', () => { + const createTwoChildCompound = () => { + const compose = vi.fn((valuesByPolicyKey: Record) => ({ + enabled: valuesByPolicyKey.parent_policy ?? false, + mode: valuesByPolicyKey.child_mode ?? 'none', + limit: valuesByPolicyKey.child_limit ?? 0, + })) + const decompose = vi.fn((editorValue: EffectivePolicyValue) => { + const value = editorValue as { enabled: boolean, mode: string, limit: number } + return { + parent_policy: value.enabled, + child_mode: value.mode, + child_limit: value.limit, + } + }) + + return { compose, decompose } + } + + it('saves every member through one compound call per target', async () => { + const compound = createTwoChildCompound() + const saveGroupPolicyCompound = vi.fn().mockResolvedValue(null) + + const result = await saveCompoundPolicyValue({ + scope: 'group', + policyKey: 'parent_policy', + value: { enabled: true, mode: 'strict', limit: 3 }, + targetIds: ['finance', 'legal'], + allowChildOverride: true, + policiesStore: { + saveSystemPolicyCompound: vi.fn(), + saveGroupPolicyCompound, + saveUserPolicyForUserCompound: vi.fn(), + }, + compound, + compositeChildren: ['child_mode', 'child_limit'], + }) + + const expectedValues = { parent_policy: true, child_mode: 'strict', child_limit: 3 } + const expectedAllowChildOverride = { parent_policy: true, child_mode: true, child_limit: true } + expect(compound.decompose).toHaveBeenCalledWith({ enabled: true, mode: 'strict', limit: 3 }) + expect(saveGroupPolicyCompound).toHaveBeenCalledTimes(2) + expect(saveGroupPolicyCompound).toHaveBeenCalledWith('finance', 'parent_policy', expectedValues, expectedAllowChildOverride) + expect(saveGroupPolicyCompound).toHaveBeenCalledWith('legal', 'parent_policy', expectedValues, expectedAllowChildOverride) + expect(result).toEqual({ + handled: true, + savedValue: { enabled: true, mode: 'strict', limit: 3 }, + }) + }) + + it('does not handle the save when the backend exposes no composite children', async () => { + const compound = createTwoChildCompound() + const saveSystemPolicyCompound = vi.fn() + + const result = await saveCompoundPolicyValue({ + scope: 'system', + policyKey: 'parent_policy', + value: { enabled: true, mode: 'strict', limit: 3 }, + targetIds: [], + allowChildOverride: true, + policiesStore: { + saveSystemPolicyCompound, + saveGroupPolicyCompound: vi.fn(), + saveUserPolicyForUserCompound: vi.fn(), + }, + compound, + compositeChildren: [], + }) + + expect(result).toEqual({ handled: false }) + expect(compound.decompose).not.toHaveBeenCalled() + expect(saveSystemPolicyCompound).not.toHaveBeenCalled() + }) + + it('hydrates system, group and user rules with keyed values from every member', () => { + const compound = createTwoChildCompound() + + const result = hydrateCompoundPolicyRules({ + parent: createMemberRecords('parent_policy', { + systemPolicy: createPersistedSystemPolicy({ value: true, allowChildOverride: false }), + groupPolicies: [createPolicyRule({ id: 'parent-finance', value: true })], + }), + children: [ + createMemberRecords('child_mode', { + systemPolicy: createPersistedSystemPolicy({ value: 'strict' }), + groupPolicies: [createPolicyRule({ id: 'mode-finance', value: 'relaxed' })], + userPolicies: [createPolicyRule({ scope: 'user', id: 'mode-user1', targetId: 'user1', value: 'strict' })], + }), + createMemberRecords('child_limit', { + systemPolicy: createPersistedSystemPolicy({ value: 5 }), + groupPolicies: [createPolicyRule({ id: 'limit-finance', value: 2 })], + }), + ], + compound, + }) + + expect(compound.compose).toHaveBeenCalledWith({ parent_policy: true, child_mode: 'strict', child_limit: 5 }) + expect(compound.compose).toHaveBeenCalledWith({ parent_policy: true, child_mode: 'relaxed', child_limit: 2 }) + expect(compound.compose).toHaveBeenCalledWith({ child_mode: 'strict' }) + expect(result.explicitSystemRule).toEqual({ + id: 'system-default', + scope: 'system', + targetId: null, + allowChildOverride: false, + value: { enabled: true, mode: 'strict', limit: 5 }, + }) + expect(result.groupRules).toEqual([ + { + id: 'parent-finance', + scope: 'group', + targetId: 'finance', + allowChildOverride: true, + value: { enabled: true, mode: 'relaxed', limit: 2 }, + canRemove: undefined, + }, + ]) + expect(result.userRules).toEqual([ + { + id: 'mode-user1', + scope: 'user', + targetId: 'user1', + allowChildOverride: true, + value: { enabled: false, mode: 'strict', limit: 0 }, + canRemove: undefined, + }, + ]) + }) + + it('clears system values for every compound member', async () => { + const saveSystemPolicy = vi.fn().mockResolvedValue(null) + + await clearCompoundPolicyTarget('system', ['parent_policy', 'child_mode', 'child_limit'], undefined, { + saveSystemPolicy, + clearGroupPolicy: vi.fn(), + clearUserPolicyForUser: vi.fn(), + }) + + expect(saveSystemPolicy).toHaveBeenCalledTimes(3) + expect(saveSystemPolicy).toHaveBeenCalledWith('parent_policy', null, false) + expect(saveSystemPolicy).toHaveBeenCalledWith('child_mode', null, false) + expect(saveSystemPolicy).toHaveBeenCalledWith('child_limit', null, false) + }) + }) }) diff --git a/src/tests/views/Settings/PolicyWorkbench/settings/expiration-rules/useRealPolicyWorkbench.spec.ts b/src/tests/views/Settings/PolicyWorkbench/settings/expiration-rules/useRealPolicyWorkbench.spec.ts index e3d2e3a943..6178c2a8c9 100644 --- a/src/tests/views/Settings/PolicyWorkbench/settings/expiration-rules/useRealPolicyWorkbench.spec.ts +++ b/src/tests/views/Settings/PolicyWorkbench/settings/expiration-rules/useRealPolicyWorkbench.spec.ts @@ -6,11 +6,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { + clearGroupPolicy, + fetchEffectivePolicies, fetchGroupPolicy, getPolicy, resetWorkbenchHarness, - saveGroupPolicy, + saveGroupPolicyCompound, saveSystemPolicy, + saveSystemPolicyCompound, } from '../workbenchTestUtils' import { createRealPolicyWorkbenchState } from '../../../../../../views/Settings/PolicyWorkbench/useRealPolicyWorkbench' @@ -22,7 +25,7 @@ describe('expiration rules workbench', () => { it('presents unified request expiration summary and hides standalone renewal setting', () => { getPolicy.mockImplementation((key: string) => { if (key === 'maximum_validity') { - return { effectiveValue: 86400, groupCount: 1, userCount: 0, sourceScope: 'system', editableByCurrentActor: true } + return { effectiveValue: 86400, groupCount: 1, userCount: 0, sourceScope: 'system', editableByCurrentActor: true, meta: { compositeChildren: ['renewal_interval'] } } } if (key === 'renewal_interval') { @@ -47,7 +50,7 @@ describe('expiration rules workbench', () => { it('saves unified request expiration system draft to both policy keys', async () => { getPolicy.mockImplementation((key: string) => { if (key === 'maximum_validity') { - return { effectiveValue: 0, sourceScope: 'system' } + return { effectiveValue: 0, sourceScope: 'system', meta: { compositeChildren: ['renewal_interval'] } } } if (key === 'renewal_interval') { @@ -67,8 +70,11 @@ describe('expiration rules workbench', () => { await state.saveDraft() - expect(saveSystemPolicy).toHaveBeenCalledWith('maximum_validity', 86400, true) - expect(saveSystemPolicy).toHaveBeenCalledWith('renewal_interval', 3600, true) + expect(saveSystemPolicyCompound).toHaveBeenCalledWith( + 'maximum_validity', + { maximum_validity: 86400, renewal_interval: 3600 }, + { maximum_validity: true, renewal_interval: true }, + ) }) it('rejects renewal interval without maximum validity in unified draft', async () => { @@ -228,7 +234,7 @@ describe('expiration rules workbench', () => { it('hydrates unified request expiration group rules from both persisted keys', async () => { getPolicy.mockImplementation((key: string) => { if (key === 'maximum_validity') { - return { effectiveValue: 0, sourceScope: 'system' } + return { effectiveValue: 0, sourceScope: 'system', meta: { compositeChildren: ['renewal_interval'] } } } if (key === 'renewal_interval') { @@ -283,9 +289,65 @@ describe('expiration rules workbench', () => { }) }) + it('reloads persisted rules when clearing one request expiration member fails', async () => { + getPolicy.mockImplementation((key: string) => { + if (key === 'maximum_validity') { + return { effectiveValue: 0, sourceScope: 'system', meta: { compositeChildren: ['renewal_interval'] } } + } + + return { effectiveValue: 0, sourceScope: 'system' } + }) + + fetchGroupPolicy.mockImplementation(async (groupId: string, policyKey: string) => { + if (groupId !== 'finance' || (policyKey !== 'maximum_validity' && policyKey !== 'renewal_interval')) { + return null + } + + return { + policyKey, + scope: 'group', + targetId: groupId, + value: policyKey === 'maximum_validity' ? 7200 : 600, + allowChildOverride: true, + visibleToChild: true, + allowedValues: [], + } + }) + + clearGroupPolicy.mockImplementation(async (_groupId: string, policyKey: string) => { + if (policyKey === 'renewal_interval') { + throw new Error('Could not clear renewal interval') + } + + return null + }) + + const state = createRealPolicyWorkbenchState() + state.openSetting('maximum_validity') + + await vi.waitFor(() => { + expect(state.visibleGroupRules).toHaveLength(1) + }) + + fetchGroupPolicy.mockClear() + + await expect(state.removeRule(state.visibleGroupRules[0]?.id ?? '')).rejects.toThrow('Could not clear renewal interval') + + expect(clearGroupPolicy).toHaveBeenCalledWith('finance', 'maximum_validity') + expect(clearGroupPolicy).toHaveBeenCalledWith('finance', 'renewal_interval') + expect(state.duplicateMessage).toBe('Could not clear renewal interval') + expect(fetchEffectivePolicies).toHaveBeenCalled() + expect(fetchGroupPolicy).toHaveBeenCalledWith('finance', 'renewal_interval') + expect(state.visibleGroupRules).toHaveLength(1) + }) + it('locks lower-level customization for group-admin request expiration group rules', async () => { getPolicy.mockImplementation((key: string) => { - if (key === 'maximum_validity' || key === 'renewal_interval') { + if (key === 'maximum_validity') { + return { effectiveValue: 0, sourceScope: 'system', editableByCurrentActor: true, meta: { compositeChildren: ['renewal_interval'] } } + } + + if (key === 'renewal_interval') { return { effectiveValue: 0, sourceScope: 'system', editableByCurrentActor: true } } @@ -310,7 +372,11 @@ describe('expiration rules workbench', () => { await state.saveDraft() - expect(saveGroupPolicy).toHaveBeenCalledWith('board', 'maximum_validity', 86400, false) - expect(saveGroupPolicy).toHaveBeenCalledWith('board', 'renewal_interval', 3600, false) + expect(saveGroupPolicyCompound).toHaveBeenCalledWith( + 'board', + 'maximum_validity', + { maximum_validity: 86400, renewal_interval: 3600 }, + { maximum_validity: false, renewal_interval: false }, + ) }) }) diff --git a/src/tests/views/Settings/PolicyWorkbench/settings/signature-text/useRealPolicyWorkbench.spec.ts b/src/tests/views/Settings/PolicyWorkbench/settings/signature-text/useRealPolicyWorkbench.spec.ts index 639b95dedc..0a4cbbbc87 100644 --- a/src/tests/views/Settings/PolicyWorkbench/settings/signature-text/useRealPolicyWorkbench.spec.ts +++ b/src/tests/views/Settings/PolicyWorkbench/settings/signature-text/useRealPolicyWorkbench.spec.ts @@ -12,7 +12,7 @@ import { fetchGroupPolicy, getPolicy, resetWorkbenchHarness, - saveGroupPolicy, + saveGroupPolicyCompound } from '../workbenchTestUtils' import { createRealPolicyWorkbenchState } from '../../../../../../views/Settings/PolicyWorkbench/useRealPolicyWorkbench' @@ -131,6 +131,14 @@ describe('signature stamp workbench', () => { return null }) + getPolicy.mockImplementation((key: string) => { + if (key === 'signature_stamp') { + return { effectiveValue: 'parallel', sourceScope: 'system', meta: { compositeChildren: ['collect_metadata'] } } + } + + return { effectiveValue: 'parallel', sourceScope: 'system' } + }) + const state = createRealPolicyWorkbenchState() state.openSetting('signature_stamp') @@ -156,6 +164,10 @@ describe('signature stamp workbench', () => { }) getPolicy.mockImplementation((key: string) => { + if (key === 'signature_stamp') { + return { effectiveValue: 'parallel', sourceScope: 'system', meta: { compositeChildren: ['collect_metadata'] } } + } + if (key === 'collect_metadata') { return { effectiveValue: false, sourceScope: 'system' } } @@ -227,6 +239,14 @@ describe('signature stamp workbench', () => { render_mode: 'default', }) + getPolicy.mockImplementation((key: string) => { + if (key === 'signature_stamp') { + return { effectiveValue: signatureStampValue, sourceScope: 'system', meta: { compositeChildren: ['collect_metadata'] } } + } + + return { effectiveValue: 'parallel', sourceScope: 'system' } + }) + const state = createRealPolicyWorkbenchState() state.openSetting('signature_stamp') state.startEditor({ scope: 'group' }) @@ -238,8 +258,12 @@ describe('signature stamp workbench', () => { await state.saveDraft() - expect(saveGroupPolicy).toHaveBeenCalledWith('finance', 'signature_stamp', signatureStampValue, true) - expect(saveGroupPolicy).toHaveBeenCalledWith('finance', 'collect_metadata', true, true) + expect(saveGroupPolicyCompound).toHaveBeenCalledWith( + 'finance', + 'signature_stamp', + { signature_stamp: signatureStampValue, collect_metadata: true }, + { signature_stamp: true, collect_metadata: true }, + ) }) it('saves signature stamp group rule with lower-level editing disabled for both companion policies', async () => { @@ -253,6 +277,14 @@ describe('signature stamp workbench', () => { render_mode: 'graphic', }) + getPolicy.mockImplementation((key: string) => { + if (key === 'signature_stamp') { + return { effectiveValue: signatureStampValue, sourceScope: 'system', meta: { compositeChildren: ['collect_metadata'] } } + } + + return { effectiveValue: 'parallel', sourceScope: 'system' } + }) + const state = createRealPolicyWorkbenchState() state.openSetting('signature_stamp') state.startEditor({ scope: 'group' }) @@ -265,8 +297,12 @@ describe('signature stamp workbench', () => { await state.saveDraft() - expect(saveGroupPolicy).toHaveBeenCalledWith('board', 'signature_stamp', signatureStampValue, false) - expect(saveGroupPolicy).toHaveBeenCalledWith('board', 'collect_metadata', false, false) + expect(saveGroupPolicyCompound).toHaveBeenCalledWith( + 'board', + 'signature_stamp', + { signature_stamp: signatureStampValue, collect_metadata: false }, + { signature_stamp: false, collect_metadata: false }, + ) }) it('blocks signature stamp account rules when a persisted group rule disables lower-level editing', async () => { @@ -389,6 +425,14 @@ describe('signature stamp workbench', () => { render_mode: 'default', }) + getPolicy.mockImplementation((key: string) => { + if (key === 'signature_stamp') { + return { effectiveValue: 'parallel', sourceScope: 'system', meta: { compositeChildren: ['collect_metadata'] } } + } + + return { effectiveValue: 'parallel', sourceScope: 'system' } + }) + const state = createRealPolicyWorkbenchState() state.openSetting('signature_stamp') state.startEditor({ scope: 'group' }) diff --git a/src/tests/views/Settings/PolicyWorkbench/settings/workbenchTestUtils.ts b/src/tests/views/Settings/PolicyWorkbench/settings/workbenchTestUtils.ts index 0f0f493d22..d0f0d1612a 100644 --- a/src/tests/views/Settings/PolicyWorkbench/settings/workbenchTestUtils.ts +++ b/src/tests/views/Settings/PolicyWorkbench/settings/workbenchTestUtils.ts @@ -95,6 +95,9 @@ export const fetchGroupPolicy = vi.fn() export const fetchSystemPolicy = vi.fn() export const fetchUserPolicyForUser = vi.fn() export const saveUserPolicyForUser = vi.fn() +export const saveSystemPolicyCompound = vi.fn() +export const saveGroupPolicyCompound = vi.fn() +export const saveUserPolicyForUserCompound = vi.fn() export const clearUserPreference = vi.fn() export const clearGroupPolicy = vi.fn() export const clearUserPolicyForUser = vi.fn() @@ -120,6 +123,9 @@ vi.mock('../../../../../store/policies', () => ({ fetchSystemPolicy, fetchUserPolicyForUser, saveUserPolicyForUser, + saveSystemPolicyCompound, + saveGroupPolicyCompound, + saveUserPolicyForUserCompound, clearUserPreference, clearGroupPolicy, clearUserPolicyForUser, @@ -153,6 +159,9 @@ export function resetWorkbenchHarness(): void { fetchSystemPolicy.mockReset() fetchUserPolicyForUser.mockReset() saveUserPolicyForUser.mockReset() + saveSystemPolicyCompound.mockReset() + saveGroupPolicyCompound.mockReset() + saveUserPolicyForUserCompound.mockReset() clearUserPreference.mockReset() clearGroupPolicy.mockReset() clearUserPolicyForUser.mockReset() @@ -165,6 +174,9 @@ export function resetWorkbenchHarness(): void { saveSystemPolicy.mockResolvedValue(null) saveGroupPolicy.mockResolvedValue(null) saveUserPolicyForUser.mockResolvedValue(null) + saveSystemPolicyCompound.mockResolvedValue(null) + saveGroupPolicyCompound.mockResolvedValue(null) + saveUserPolicyForUserCompound.mockResolvedValue(null) clearUserPreference.mockResolvedValue(null) clearGroupPolicy.mockResolvedValue(null) clearUserPolicyForUser.mockResolvedValue(null) diff --git a/src/types/index.ts b/src/types/index.ts index 1deb7b63aa..49cf08e9ea 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -120,6 +120,7 @@ export type UserPreferenceCompoundPolicyWritePayload = ApiRequestJsonBody export type UserCompoundPolicyWritePayload = ApiRequestJsonBody export type UserCompoundPolicyWriteResponse = ApiOcsResponseData +export type CompoundPolicyWriteValues = NonNullable export type NewFilePayload = ApiComponents['schemas']['NewFile'] type OpenApiIdentifyMethodRecord = ApiComponents['schemas']['IdentifyMethod'] export type IdentifyMethodRequirement = 'required' | 'optional' diff --git a/src/views/Settings/PolicyWorkbench/policyWorkbenchCompoundPolicies.ts b/src/views/Settings/PolicyWorkbench/policyWorkbenchCompoundPolicies.ts index b6804c542b..7be6a4b682 100644 --- a/src/views/Settings/PolicyWorkbench/policyWorkbenchCompoundPolicies.ts +++ b/src/views/Settings/PolicyWorkbench/policyWorkbenchCompoundPolicies.ts @@ -3,22 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { EffectivePolicyValue } from '../../../types/index' -import { - normalizeRequestExpirationDraftValue, - type RequestExpirationDraftValue, -} from './settings/expiration-rules/model' +import type { CompoundPolicyWriteValues, EffectivePolicyValue } from '../../../types/index' +import type { RealPolicyCompoundBehavior } from './settings/realTypes' import { normalizeSignatureStampDraftValue, resolveCollectMetadataValue, } from './settings/signature-text/model' -import { - normalizeSigningExecutionSettings, - normalizeWorkerConfig, - resolveSigningMode, - serializeWorkerConfig, - type SigningExecutionSettingsValue, -} from './settings/signing-mode/model' export type PolicyScope = 'system' | 'group' | 'user' @@ -37,14 +27,17 @@ export interface PersistedSystemPolicyRecord { allowChildOverride?: boolean } -export interface CompoundPolicyHydrationContext { +export interface CompoundPolicyMemberRecords { policyKey: string - persistedSystemPolicy: PersistedSystemPolicyRecord | null - companionSystemPolicy: PersistedSystemPolicyRecord | null - persistedGroupPolicies: PolicyRuleRecord[] - companionGroupPolicies: PolicyRuleRecord[] - persistedUserPolicies: PolicyRuleRecord[] - companionUserPolicies: PolicyRuleRecord[] + systemPolicy: PersistedSystemPolicyRecord | null + groupPolicies: PolicyRuleRecord[] + userPolicies: PolicyRuleRecord[] +} + +export interface CompoundPolicyHydrationContext { + parent: CompoundPolicyMemberRecords + children: CompoundPolicyMemberRecords[] + compound: RealPolicyCompoundBehavior } export interface CompoundPolicyHydrationResult { @@ -54,9 +47,9 @@ export interface CompoundPolicyHydrationResult { } interface CompoundPolicySaveStore { - saveSystemPolicy: (policyKey: string, value: EffectivePolicyValue, allowChildOverride: boolean) => Promise - saveGroupPolicy: (targetId: string, policyKey: string, value: EffectivePolicyValue, allowChildOverride: boolean) => Promise - saveUserPolicyForUser: (targetId: string, policyKey: string, value: EffectivePolicyValue, allowChildOverride: boolean) => Promise + saveSystemPolicyCompound: (parentPolicyKey: string, values: CompoundPolicyWriteValues, allowChildOverride?: Record) => Promise + saveGroupPolicyCompound: (groupId: string, parentPolicyKey: string, values: CompoundPolicyWriteValues, allowChildOverride?: Record) => Promise + saveUserPolicyForUserCompound: (userId: string, parentPolicyKey: string, values: CompoundPolicyWriteValues, allowChildOverride?: Record) => Promise } interface CompoundPolicyClearStore { @@ -76,7 +69,8 @@ interface SaveCompoundPolicyValueContext { targetIds: string[] allowChildOverride: boolean policiesStore: CompoundPolicySaveStore - collectMetadataEffectiveValue?: EffectivePolicyValue | null | undefined + compound?: RealPolicyCompoundBehavior + compositeChildren?: string[] } export const REQUEST_EXPIRATION_POLICY_KEY = 'maximum_validity' @@ -87,231 +81,72 @@ export const SIGNATURE_STAMP_POLICY_KEY = 'signature_stamp' export const COLLECT_METADATA_POLICY_KEY = 'collect_metadata' export const REQUEST_SIGN_GROUPS_POLICY_KEY = 'groups_request_sign' -interface CompoundPolicyPair { - companionKey: string - primaryValue: EffectivePolicyValue - companionValue: EffectivePolicyValue - savedValue: EffectivePolicyValue -} - function hasPersistedValue(value: EffectivePolicyValue | null | undefined): value is EffectivePolicyValue { return value !== null && value !== undefined } -function getCompoundPolicyCompanionKey(policyKey: string): string | null { - if (isRequestExpirationPolicyKey(policyKey)) { - return REQUEST_EXPIRATION_RENEWAL_KEY - } - - if (isUnifiedSigningExecutionPolicyKey(policyKey)) { - return SIGNING_EXECUTION_WORKER_KEY - } - - if (isSignatureStampPolicyKey(policyKey)) { - return COLLECT_METADATA_POLICY_KEY - } - - return null -} - -function resolveCompoundPolicyPair( - policyKey: string, - value: EffectivePolicyValue, - collectMetadataEffectiveValue?: EffectivePolicyValue | null, -): CompoundPolicyPair | null { - if (isRequestExpirationPolicyKey(policyKey)) { - const normalizedValue = normalizeRequestExpirationDraftValue(value) - return { - companionKey: REQUEST_EXPIRATION_RENEWAL_KEY, - primaryValue: normalizedValue.maximumValidity, - companionValue: normalizedValue.renewalInterval, - savedValue: normalizedValue, - } - } - - if (isUnifiedSigningExecutionPolicyKey(policyKey)) { - const normalizedValue = normalizeSigningExecutionSettings(value) - return { - companionKey: SIGNING_EXECUTION_WORKER_KEY, - primaryValue: normalizedValue.signingMode, - companionValue: serializeWorkerConfig({ - workerType: normalizedValue.workerType, - parallelWorkers: normalizedValue.parallelWorkers, - }), - savedValue: normalizedValue, - } - } - - if (isSignatureStampPolicyKey(policyKey)) { - const normalizedValue = normalizeSignatureStampDraftValue( - value, - resolveCollectMetadataValue(collectMetadataEffectiveValue, false), - ) - return { - companionKey: COLLECT_METADATA_POLICY_KEY, - primaryValue: normalizedValue.signatureStampValue, - companionValue: normalizedValue.collectMetadataEnabled, - savedValue: normalizedValue, - } - } - - return null -} - -async function saveCompoundPolicyPair( - scope: PolicyScope, - policyKey: string, - compoundPair: CompoundPolicyPair, - targetIds: string[], - allowChildOverride: boolean, - policiesStore: CompoundPolicySaveStore, -) { - if (scope === 'system') { - await Promise.all([ - policiesStore.saveSystemPolicy(policyKey, compoundPair.primaryValue, allowChildOverride), - policiesStore.saveSystemPolicy(compoundPair.companionKey, compoundPair.companionValue, allowChildOverride), - ]) - return - } - - if (scope === 'group') { - await Promise.all(targetIds.map((targetId) => { - return Promise.all([ - policiesStore.saveGroupPolicy(targetId, policyKey, compoundPair.primaryValue, allowChildOverride), - policiesStore.saveGroupPolicy(targetId, compoundPair.companionKey, compoundPair.companionValue, allowChildOverride), - ]) - })) - return - } - - await Promise.all(targetIds.map((targetId) => { - return Promise.all([ - policiesStore.saveUserPolicyForUser(targetId, policyKey, compoundPair.primaryValue, allowChildOverride), - policiesStore.saveUserPolicyForUser(targetId, compoundPair.companionKey, compoundPair.companionValue, allowChildOverride), - ]) - })) -} - -async function clearCompoundPolicyPair( - scope: PolicyScope, - policyKey: string, - companionKey: string, - policiesStore: CompoundPolicyClearStore, - targetId?: string, -) { - if (scope === 'system') { - await Promise.all([ - policiesStore.saveSystemPolicy(policyKey, null, false), - policiesStore.saveSystemPolicy(companionKey, null, false), - ]) - return - } - - if (!targetId) { - return - } - - if (scope === 'group') { - await Promise.all([ - policiesStore.clearGroupPolicy(targetId, policyKey), - policiesStore.clearGroupPolicy(targetId, companionKey), - ]) - return - } - - await Promise.all([ - policiesStore.clearUserPolicyForUser(targetId, policyKey), - policiesStore.clearUserPolicyForUser(targetId, companionKey), - ]) -} - function mergeCompoundRulesByTarget( - primaryRules: PolicyRuleRecord[], - companionRules: PolicyRuleRecord[], + members: Array<{ policyKey: string, rules: PolicyRuleRecord[] }>, scope: 'group' | 'user', - buildValue: (primaryValue: EffectivePolicyValue | undefined, companionValue: EffectivePolicyValue | undefined) => EffectivePolicyValue, - includeCompanionOnlyRules = true, + compose: RealPolicyCompoundBehavior['compose'], + includeChildOnlyRules: boolean, ): PolicyRuleRecord[] { - const mergedRules = new Map() - - for (const rule of primaryRules) { - if (!rule.targetId) { - continue - } - - mergedRules.set(rule.targetId, { - id: rule.id, - scope, - targetId: rule.targetId, - allowChildOverride: rule.allowChildOverride, - value: buildValue(rule.value, undefined), - canRemove: rule.canRemove, - }) - } - - for (const rule of companionRules) { - if (!rule.targetId) { - continue - } - - const existingRule = mergedRules.get(rule.targetId) - if (!existingRule && !includeCompanionOnlyRules) { - continue + const mergedRules = new Map }>() + + for (const [index, member] of members.entries()) { + const isParent = index === 0 + for (const rule of member.rules) { + if (!rule.targetId) { + continue + } + + const existing = mergedRules.get(rule.targetId) + if (!existing && !isParent && !includeChildOnlyRules) { + continue + } + + const entry = existing ?? { rule, values: {} } + entry.values[member.policyKey] = rule.value + mergedRules.set(rule.targetId, entry) } - - mergedRules.set(rule.targetId, { - id: existingRule?.id ?? rule.id, - scope, - targetId: rule.targetId, - allowChildOverride: existingRule?.allowChildOverride ?? rule.allowChildOverride, - value: buildValue(existingRule?.value, rule.value), - canRemove: existingRule?.canRemove ?? rule.canRemove, - }) } - return Array.from(mergedRules.values()) -} - -export function isRequestExpirationPolicyKey(policyKey: string): boolean { - return policyKey === REQUEST_EXPIRATION_POLICY_KEY -} - -export function isUnifiedSigningExecutionPolicyKey(policyKey: string): boolean { - return policyKey === SIGNING_EXECUTION_POLICY_KEY + return Array.from(mergedRules.values()).map(({ rule, values }) => ({ + id: rule.id, + scope, + targetId: rule.targetId, + allowChildOverride: rule.allowChildOverride, + value: compose(values), + canRemove: rule.canRemove, + })) } -export function isSignatureStampPolicyKey(policyKey: string): boolean { - return policyKey === SIGNATURE_STAMP_POLICY_KEY -} +function hydrateCompoundSystemRule( + members: CompoundPolicyMemberRecords[], + compose: RealPolicyCompoundBehavior['compose'], + includeChildOnlyRules: boolean, +): PolicyRuleRecord | null { + const membersWithRequiredValue = includeChildOnlyRules ? members : members.slice(0, 1) + const hasGlobalScope = members.some((member) => member.systemPolicy?.scope === 'global') + const hasValue = membersWithRequiredValue.some((member) => hasPersistedValue(member.systemPolicy?.value)) + if (!hasGlobalScope || !hasValue) { + return null + } -export function buildRequestExpirationValue( - maximumValidity: EffectivePolicyValue | undefined, - renewalInterval: EffectivePolicyValue | undefined, -): RequestExpirationDraftValue { - const normalizedMaximum = normalizeRequestExpirationDraftValue(maximumValidity ?? null) - const normalizedRenewal = normalizeRequestExpirationDraftValue({ - maximumValidity: 0, - renewalInterval: renewalInterval ?? null, - }) + const values = Object.fromEntries(members.map((member) => [member.policyKey, member.systemPolicy?.value ?? undefined])) + const allowChildOverride = members.find((member) => member.systemPolicy?.allowChildOverride !== undefined)?.systemPolicy?.allowChildOverride return { - maximumValidity: normalizedMaximum.maximumValidity, - renewalInterval: normalizedRenewal.renewalInterval, + id: 'system-default', + scope: 'system', + targetId: null, + allowChildOverride: allowChildOverride ?? true, + value: compose(values), } } -export function buildSigningExecutionValue( - signingMode: EffectivePolicyValue | undefined, - workerConfig: EffectivePolicyValue | undefined, -): SigningExecutionSettingsValue { - const normalizedMode = normalizeSigningExecutionSettings(signingMode ?? null) - const normalizedWorker = normalizeWorkerConfig(workerConfig ?? null) - - return { - signingMode: resolveSigningMode(normalizedMode.signingMode), - workerType: normalizedWorker.workerType, - parallelWorkers: normalizedWorker.parallelWorkers, - } +export function isSignatureStampPolicyKey(policyKey: string): boolean { + return policyKey === SIGNATURE_STAMP_POLICY_KEY } export function buildSignatureStampDraftValue( @@ -325,167 +160,81 @@ export function buildSignatureStampDraftValue( } export async function saveCompoundPolicyValue(context: SaveCompoundPolicyValueContext): Promise<{ handled: true, savedValue: EffectivePolicyValue } | { handled: false }> { - const compoundPair = resolveCompoundPolicyPair( - context.policyKey, - context.value, - context.collectMetadataEffectiveValue, - ) - if (!compoundPair) { + const { compound, compositeChildren = [] } = context + if (!compound || compositeChildren.length === 0) { return { handled: false } } - await saveCompoundPolicyPair( - context.scope, - context.policyKey, - compoundPair, - context.targetIds, - context.allowChildOverride, - context.policiesStore, + const values = compound.decompose(context.value) + const allowChildOverride = Object.fromEntries( + Object.keys(values).map((policyKey) => [policyKey, context.allowChildOverride]), ) + if (context.scope === 'system') { + await context.policiesStore.saveSystemPolicyCompound(context.policyKey, values, allowChildOverride) + } else if (context.scope === 'group') { + await Promise.all(context.targetIds.map((targetId) => { + return context.policiesStore.saveGroupPolicyCompound(targetId, context.policyKey, values, allowChildOverride) + })) + } else { + await Promise.all(context.targetIds.map((targetId) => { + return context.policiesStore.saveUserPolicyForUserCompound(targetId, context.policyKey, values, allowChildOverride) + })) + } + return { handled: true, - savedValue: compoundPair.savedValue, + savedValue: compound.compose(values), } } export async function clearCompoundUserPreferences( - policyKey: string, + policyKeys: string[], policiesStore: CompoundPolicyPreferenceClearStore, -): Promise { - const companionKey = getCompoundPolicyCompanionKey(policyKey) - if (!companionKey) { - return false - } - - await Promise.all([ - policiesStore.clearUserPreference(policyKey), - policiesStore.clearUserPreference(companionKey), - ]) - return true +): Promise { + await Promise.all(policyKeys.map((policyKey) => policiesStore.clearUserPreference(policyKey))) } export async function clearCompoundPolicyTarget( scope: PolicyScope, - policyKey: string, + policyKeys: string[], targetId: string | undefined, policiesStore: CompoundPolicyClearStore, -): Promise { - const companionKey = getCompoundPolicyCompanionKey(policyKey) - if (!companionKey) { - return false +): Promise { + if (scope === 'system') { + await Promise.all(policyKeys.map((policyKey) => policiesStore.saveSystemPolicy(policyKey, null, false))) + return } - await clearCompoundPolicyPair(scope, policyKey, companionKey, policiesStore, targetId) - return true -} - -export function hydrateCompoundPolicyRules(context: CompoundPolicyHydrationContext): CompoundPolicyHydrationResult | null { - const { - policyKey, - persistedSystemPolicy, - companionSystemPolicy, - persistedGroupPolicies, - companionGroupPolicies, - persistedUserPolicies, - companionUserPolicies, - } = context - - if (isRequestExpirationPolicyKey(policyKey)) { - const primaryHasValue = hasPersistedValue(persistedSystemPolicy?.value) - const companionHasValue = hasPersistedValue(companionSystemPolicy?.value) - return { - explicitSystemRule: (persistedSystemPolicy?.scope === 'global' || companionSystemPolicy?.scope === 'global') && (primaryHasValue || companionHasValue) - ? { - id: 'system-default', - scope: 'system', - targetId: null, - allowChildOverride: persistedSystemPolicy?.allowChildOverride ?? companionSystemPolicy?.allowChildOverride ?? true, - value: buildRequestExpirationValue(persistedSystemPolicy?.value ?? undefined, companionSystemPolicy?.value ?? undefined), - } - : null, - groupRules: mergeCompoundRulesByTarget( - persistedGroupPolicies, - companionGroupPolicies, - 'group', - buildRequestExpirationValue, - ), - userRules: mergeCompoundRulesByTarget( - persistedUserPolicies, - companionUserPolicies, - 'user', - buildRequestExpirationValue, - ), - } + if (!targetId) { + return } - if (isUnifiedSigningExecutionPolicyKey(policyKey)) { - const primaryHasValue = hasPersistedValue(persistedSystemPolicy?.value) - const companionHasValue = hasPersistedValue(companionSystemPolicy?.value) - return { - explicitSystemRule: (persistedSystemPolicy?.scope === 'global' || companionSystemPolicy?.scope === 'global') && (primaryHasValue || companionHasValue) - ? { - id: 'system-default', - scope: 'system', - targetId: null, - allowChildOverride: persistedSystemPolicy?.allowChildOverride ?? companionSystemPolicy?.allowChildOverride ?? true, - value: buildSigningExecutionValue(persistedSystemPolicy?.value ?? undefined, companionSystemPolicy?.value ?? undefined), - } - : null, - groupRules: mergeCompoundRulesByTarget( - persistedGroupPolicies, - companionGroupPolicies, - 'group', - buildSigningExecutionValue, - ), - userRules: mergeCompoundRulesByTarget( - persistedUserPolicies, - companionUserPolicies, - 'user', - buildSigningExecutionValue, - ), - } + if (scope === 'group') { + await Promise.all(policyKeys.map((policyKey) => policiesStore.clearGroupPolicy(targetId, policyKey))) + return } - if (isSignatureStampPolicyKey(policyKey)) { - const primaryHasValue = hasPersistedValue(persistedSystemPolicy?.value) - return { - explicitSystemRule: (persistedSystemPolicy?.scope === 'global' || companionSystemPolicy?.scope === 'global') && primaryHasValue - ? { - id: 'system-default', - scope: 'system', - targetId: null, - allowChildOverride: persistedSystemPolicy?.allowChildOverride ?? companionSystemPolicy?.allowChildOverride ?? true, - value: buildSignatureStampDraftValue(persistedSystemPolicy?.value ?? undefined, companionSystemPolicy?.value ?? undefined), - } - : null, - groupRules: persistedGroupPolicies - .filter((rule) => !!rule.targetId) - .map((rule) => { - const collectMetadataRule = companionGroupPolicies.find((metadataRule) => metadataRule.targetId === rule.targetId) - return { - id: rule.id, - scope: 'group' as const, - targetId: rule.targetId, - allowChildOverride: rule.allowChildOverride, - value: buildSignatureStampDraftValue(rule.value, collectMetadataRule?.value), - canRemove: rule.canRemove, - } - }), - userRules: persistedUserPolicies - .filter((rule) => !!rule.targetId) - .map((rule) => { - const collectMetadataRule = companionUserPolicies.find((metadataRule) => metadataRule.targetId === rule.targetId) - return { - id: rule.id, - scope: 'user' as const, - targetId: rule.targetId, - allowChildOverride: rule.allowChildOverride, - value: buildSignatureStampDraftValue(rule.value, collectMetadataRule?.value), - } - }), - } - } + await Promise.all(policyKeys.map((policyKey) => policiesStore.clearUserPolicyForUser(targetId, policyKey))) +} - return null +export function hydrateCompoundPolicyRules({ parent, children, compound }: CompoundPolicyHydrationContext): CompoundPolicyHydrationResult { + const members = [parent, ...children] + const includeChildOnlyRules = compound.includeChildOnlyRules ?? true + + return { + explicitSystemRule: hydrateCompoundSystemRule(members, compound.compose, includeChildOnlyRules), + groupRules: mergeCompoundRulesByTarget( + members.map((member) => ({ policyKey: member.policyKey, rules: member.groupPolicies })), + 'group', + compound.compose, + includeChildOnlyRules, + ), + userRules: mergeCompoundRulesByTarget( + members.map((member) => ({ policyKey: member.policyKey, rules: member.userPolicies })), + 'user', + compound.compose, + includeChildOnlyRules, + ), + } } diff --git a/src/views/Settings/PolicyWorkbench/settings/expiration-rules/realDefinitions.ts b/src/views/Settings/PolicyWorkbench/settings/expiration-rules/realDefinitions.ts index e388f944d6..1f4d508326 100644 --- a/src/views/Settings/PolicyWorkbench/settings/expiration-rules/realDefinitions.ts +++ b/src/views/Settings/PolicyWorkbench/settings/expiration-rules/realDefinitions.ts @@ -34,6 +34,25 @@ export const maximumValidityRealDefinition: RealPolicySettingDefinition = { }, editor: RequestExpirationRuleEditor, supportedScopes: ['system', 'group', 'user'], + compound: { + compose: (valuesByPolicyKey) => { + const maximumValidity = normalizeRequestExpirationDraftValue(valuesByPolicyKey.maximum_validity ?? null).maximumValidity + const renewalInterval = normalizeRequestExpirationDraftValue({ + maximumValidity: 0, + renewalInterval: valuesByPolicyKey.renewal_interval ?? null, + }).renewalInterval + + return { maximumValidity, renewalInterval } + }, + decompose: (editorValue) => { + const normalizedValue = normalizeRequestExpirationDraftValue(editorValue) + + return { + maximum_validity: normalizedValue.maximumValidity, + renewal_interval: normalizedValue.renewalInterval, + } + }, + }, createEmptyValue: () => normalizeRequestExpirationDraftValue(DEFAULT_MAXIMUM_VALIDITY), normalizeDraftValue: (value: EffectivePolicyValue) => normalizeRequestExpirationDraftValue(value), hasSelectableDraftValue: (value: EffectivePolicyValue) => hasValidRequestExpirationCombination(value), diff --git a/src/views/Settings/PolicyWorkbench/settings/realTypes.ts b/src/views/Settings/PolicyWorkbench/settings/realTypes.ts index 8125fefa76..c97fdac134 100644 --- a/src/views/Settings/PolicyWorkbench/settings/realTypes.ts +++ b/src/views/Settings/PolicyWorkbench/settings/realTypes.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { EffectivePolicyState, EffectivePolicyValue } from '../../../../types/index' +import type { CompoundPolicyWriteValues, EffectivePolicyState, EffectivePolicyValue } from '../../../../types/index' export type { EffectivePolicyState, EffectivePolicyValue } @@ -37,6 +37,17 @@ export interface RealPolicyPersonalPreferenceBehavior { clearPreference?: (context: RealPolicyPersonalPreferenceContext) => Promise } +export interface RealPolicyCompoundBehavior { + compose: (valuesByPolicyKey: Record) => EffectivePolicyValue + decompose: (editorValue: EffectivePolicyValue) => CompoundPolicyWriteValues + /** + * When false, group and user rules that only exist on a child policy are ignored, + * and the system rule requires a value on the parent policy. + * Use it when a child is also a standalone setting. Defaults to true. + */ + includeChildOnlyRules?: boolean +} + export interface RealPolicyAllowOverrideContext { scope: RealPolicyScope editorMode: 'create' | 'edit' | null @@ -62,6 +73,7 @@ export interface RealPolicySettingDefinition { supportedScopes?: ReadonlyArray groupAdminBehavior?: RealPolicyGroupAdminBehavior personalPreferenceBehavior?: RealPolicyPersonalPreferenceBehavior + compound?: RealPolicyCompoundBehavior editor: unknown editorProps?: Record resolveEditorProps?: (policy: EffectivePolicyState | null, baseEditorProps: Record) => Record @@ -96,3 +108,4 @@ export interface RealPolicySettingDefinition { summarizeValue: (value: EffectivePolicyValue) => string formatAllowOverride: (allowChildOverride: boolean) => string } + diff --git a/src/views/Settings/PolicyWorkbench/settings/signature-text/realDefinition.ts b/src/views/Settings/PolicyWorkbench/settings/signature-text/realDefinition.ts index 720a904d80..6c74a66c7d 100644 --- a/src/views/Settings/PolicyWorkbench/settings/signature-text/realDefinition.ts +++ b/src/views/Settings/PolicyWorkbench/settings/signature-text/realDefinition.ts @@ -7,12 +7,13 @@ import { t } from '@nextcloud/l10n' import SignatureTextRuleEditor from './SignatureTextRuleEditor.vue' -import type { EffectivePolicyMeta, EffectivePolicyState, EffectivePolicyValue } from '../../../../../types/index' +import type { CompoundPolicyWriteValues, EffectivePolicyMeta, EffectivePolicyState, EffectivePolicyValue } from '../../../../../types/index' import type { RealPolicySettingDefinition } from '../realTypes' import { getDefaultSignatureTextPolicyConfig, normalizeSignatureStampDraftValue, normalizeSignatureTextPolicyConfig, + resolveCollectMetadataValue, serializeSignatureTextPolicyConfig, } from './model' import { signatureStampPersonalPreferenceBehavior } from './personalPreferenceBehavior' @@ -66,6 +67,28 @@ export const signatureTextRealDefinition: RealPolicySettingDefinition = { allowGroupRuleCreationFromDescendantDelegation: true, }, personalPreferenceBehavior: signatureStampPersonalPreferenceBehavior, + compound: { + includeChildOnlyRules: false, + compose: (valuesByPolicyKey) => normalizeSignatureStampDraftValue( + valuesByPolicyKey.signature_stamp, + resolveCollectMetadataValue(valuesByPolicyKey.collect_metadata, false), + ), + decompose: (editorValue): CompoundPolicyWriteValues => { + const normalizedValue = normalizeSignatureStampDraftValue(editorValue) + const hasCollectMetadataChoice = typeof editorValue === 'object' + && editorValue !== null + && 'collectMetadataEnabled' in editorValue + + if (!hasCollectMetadataChoice) { + return { signature_stamp: normalizedValue.signatureStampValue } + } + + return { + signature_stamp: normalizedValue.signatureStampValue, + collect_metadata: normalizedValue.collectMetadataEnabled, + } + }, + }, editor: SignatureTextRuleEditor, editorProps: {}, resolveEditorProps: (policy: EffectivePolicyState | null, baseEditorProps: Record) => { diff --git a/src/views/Settings/PolicyWorkbench/settings/signing-mode/realDefinitions.ts b/src/views/Settings/PolicyWorkbench/settings/signing-mode/realDefinitions.ts index 78a43e1e3d..ee80524b64 100644 --- a/src/views/Settings/PolicyWorkbench/settings/signing-mode/realDefinitions.ts +++ b/src/views/Settings/PolicyWorkbench/settings/signing-mode/realDefinitions.ts @@ -28,6 +28,29 @@ export const signingModeRealDefinition: RealPolicySettingDefinition = { canRenderPolicy: () => false, }, supportedScopes: ['system'], + compound: { + compose: (valuesByPolicyKey) => { + const normalizedMode = normalizeSigningExecutionSettings(valuesByPolicyKey.signing_mode ?? null) + const normalizedWorker = normalizeWorkerConfig(valuesByPolicyKey.worker_config ?? null) + + return { + signingMode: resolveSigningMode(normalizedMode.signingMode), + workerType: normalizedWorker.workerType, + parallelWorkers: normalizedWorker.parallelWorkers, + } + }, + decompose: (editorValue) => { + const normalizedValue = normalizeSigningExecutionSettings(editorValue) + + return { + signing_mode: normalizedValue.signingMode, + worker_config: serializeWorkerConfig({ + workerType: normalizedValue.workerType, + parallelWorkers: normalizedValue.parallelWorkers, + }), + } + }, + }, editor: SigningModeRuleEditor, createEmptyValue: () => normalizeSigningExecutionSettings('sync'), normalizeDraftValue: (value: EffectivePolicyValue) => normalizeSigningExecutionSettings(value), diff --git a/src/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.ts b/src/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.ts index d05e08ea0c..6473986589 100644 --- a/src/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.ts +++ b/src/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.ts @@ -22,23 +22,15 @@ import { serializeRequestSignGroups, } from './settings/request-sign-groups/model' import { - buildRequestExpirationValue, buildSignatureStampDraftValue, - buildSigningExecutionValue, clearCompoundPolicyTarget, clearCompoundUserPreferences, hydrateCompoundPolicyRules, - isRequestExpirationPolicyKey, isSignatureStampPolicyKey, - isUnifiedSigningExecutionPolicyKey, - REQUEST_EXPIRATION_POLICY_KEY, - REQUEST_EXPIRATION_RENEWAL_KEY, REQUEST_SIGN_GROUPS_POLICY_KEY, saveCompoundPolicyValue, - SIGNATURE_STAMP_POLICY_KEY, - SIGNING_EXECUTION_POLICY_KEY, - SIGNING_EXECUTION_WORKER_KEY, COLLECT_METADATA_POLICY_KEY, + type CompoundPolicyMemberRecords, type PersistedSystemPolicyRecord, type PolicyRuleRecord, type PolicyScope, @@ -279,47 +271,42 @@ export function createRealPolicyWorkbenchState() { return Math.max(groupCount, cachedGroupCount ?? 0) } + function resolveCompoundPolicyKeys(policyKey: string): string[] { + const compound = realDefinitions[policyKey as keyof typeof realDefinitions]?.compound + const compositeChildren = compound ? policiesStore.getPolicy(policyKey)?.meta?.compositeChildren ?? [] : [] + + return compositeChildren.length > 0 ? [policyKey, ...compositeChildren] : [] + } + + function composeEffectiveValue(policyKey: string, parentValue: T): T | EffectivePolicyValue { + const compound = realDefinitions[policyKey as keyof typeof realDefinitions]?.compound + const [, ...childKeys] = resolveCompoundPolicyKeys(policyKey) + if (!compound || childKeys.length === 0) { + return parentValue + } + + return compound.compose({ + [policyKey]: parentValue, + ...Object.fromEntries(childKeys.map((childKey) => [childKey, policiesStore.getPolicy(childKey)?.effectiveValue])), + }) + } + const visibleSettingSummaries = computed(() => { const isGroupAdminMode = viewMode.value === 'group-admin' return Object.values(realDefinitions) .map((definition) => { const policy = policiesStore.getPolicy(definition.key) - const isRequestExpiration = isRequestExpirationPolicyKey(definition.key) - const isUnifiedSigningExecution = isUnifiedSigningExecutionPolicyKey(definition.key) - const renewalPolicy = isRequestExpiration - ? policiesStore.getPolicy(REQUEST_EXPIRATION_RENEWAL_KEY) - : null - const workerPolicy = isUnifiedSigningExecution - ? policiesStore.getPolicy(SIGNING_EXECUTION_WORKER_KEY) - : null - const hasEffectiveValue = isRequestExpiration - ? ( - (policy?.effectiveValue !== null && policy?.effectiveValue !== undefined) - || (renewalPolicy?.effectiveValue !== null && renewalPolicy?.effectiveValue !== undefined) - ) - : isUnifiedSigningExecution - ? ( - (policy?.effectiveValue !== null && policy?.effectiveValue !== undefined) - || (workerPolicy?.effectiveValue !== null && workerPolicy?.effectiveValue !== undefined) - ) - : (policy?.effectiveValue !== null && policy?.effectiveValue !== undefined) + const includeChildOnlyRules = definition.compound?.includeChildOnlyRules ?? true + const countedPolicies = includeChildOnlyRules + ? resolveCompoundPolicyKeys(definition.key).slice(1).map((childKey) => policiesStore.getPolicy(childKey)) + : [] + const memberPolicies = [policy, ...countedPolicies] + const hasEffectiveValue = memberPolicies.some((memberPolicy) => memberPolicy?.effectiveValue !== null && memberPolicy?.effectiveValue !== undefined) const isActiveSetting = activeSettingKey.value === definition.key - const summaryValue = isRequestExpiration - ? buildRequestExpirationValue(policy?.effectiveValue, renewalPolicy?.effectiveValue) - : isUnifiedSigningExecution - ? buildSigningExecutionValue(policy?.effectiveValue, workerPolicy?.effectiveValue) - : policy?.effectiveValue - const groupCount = isRequestExpiration - ? Math.max(policy?.groupCount ?? 0, renewalPolicy?.groupCount ?? 0) - : isUnifiedSigningExecution - ? Math.max(policy?.groupCount ?? 0, workerPolicy?.groupCount ?? 0) - : (policy?.groupCount ?? 0) - const userCount = isRequestExpiration - ? Math.max(policy?.userCount ?? 0, renewalPolicy?.userCount ?? 0) - : isUnifiedSigningExecution - ? Math.max(policy?.userCount ?? 0, workerPolicy?.userCount ?? 0) - : (policy?.userCount ?? 0) + const summaryValue = composeEffectiveValue(definition.key, policy?.effectiveValue) + const groupCount = Math.max(...memberPolicies.map((memberPolicy) => memberPolicy?.groupCount ?? 0)) + const userCount = Math.max(...memberPolicies.map((memberPolicy) => memberPolicy?.userCount ?? 0)) const cachedCounts = hydratedRuleCounts.value[definition.key] return { @@ -384,17 +371,7 @@ export function createRealPolicyWorkbenchState() { } if (sourceScope === 'system') { - const value = isRequestExpirationPolicyKey(activeDefinition.value.key) - ? buildRequestExpirationValue( - policy.effectiveValue, - policiesStore.getPolicy(REQUEST_EXPIRATION_RENEWAL_KEY)?.effectiveValue, - ) - : isUnifiedSigningExecutionPolicyKey(activeDefinition.value.key) - ? buildSigningExecutionValue( - policy.effectiveValue, - policiesStore.getPolicy(SIGNING_EXECUTION_WORKER_KEY)?.effectiveValue, - ) - : policy.effectiveValue + const value = composeEffectiveValue(activeDefinition.value.key, policy.effectiveValue) return { id: 'system-inherited-default', @@ -409,17 +386,7 @@ export function createRealPolicyWorkbenchState() { return explicitSystemRule.value } - const explicitValue = isRequestExpirationPolicyKey(activeDefinition.value.key) - ? buildRequestExpirationValue( - policy.effectiveValue, - policiesStore.getPolicy(REQUEST_EXPIRATION_RENEWAL_KEY)?.effectiveValue, - ) - : isUnifiedSigningExecutionPolicyKey(activeDefinition.value.key) - ? buildSigningExecutionValue( - policy.effectiveValue, - policiesStore.getPolicy(SIGNING_EXECUTION_WORKER_KEY)?.effectiveValue, - ) - : policy.effectiveValue + const explicitValue = composeEffectiveValue(activeDefinition.value.key, policy.effectiveValue) return { id: 'system-default', @@ -436,21 +403,10 @@ export function createRealPolicyWorkbenchState() { } const policy = activePolicyState.value - const fallbackValue = isRequestExpirationPolicyKey(activeDefinition.value.key) - ? buildRequestExpirationValue( - activeDefinition.value.getFallbackSystemDefault(policy?.effectiveValue, policy?.sourceScope, policy), - policiesStore.getPolicy(REQUEST_EXPIRATION_RENEWAL_KEY)?.effectiveValue, - ) - : isUnifiedSigningExecutionPolicyKey(activeDefinition.value.key) - ? buildSigningExecutionValue( - activeDefinition.value.getFallbackSystemDefault(policy?.effectiveValue, policy?.sourceScope, policy), - policiesStore.getPolicy(SIGNING_EXECUTION_WORKER_KEY)?.effectiveValue, - ) - : activeDefinition.value.getFallbackSystemDefault( - policy?.effectiveValue, - policy?.sourceScope, - policy, - ) + const fallbackValue = composeEffectiveValue( + activeDefinition.value.key, + activeDefinition.value.getFallbackSystemDefault(policy?.effectiveValue, policy?.sourceScope, policy), + ) if (fallbackValue === null || fallbackValue === undefined) { return null @@ -868,9 +824,6 @@ export function createRealPolicyWorkbenchState() { const currentRequestId = hydratePersistedRulesRequestId.value + 1 hydratePersistedRulesRequestId.value = currentRequestId rulesLoading.value = true - const shouldMergeRequestExpiration = isRequestExpirationPolicyKey(policyKey) - const shouldMergeSigningExecution = isUnifiedSigningExecutionPolicyKey(policyKey) - const shouldMergeSignatureStamp = isSignatureStampPolicyKey(policyKey) const targetsPreloaded = await Promise.all([ loadTargets('group', ''), @@ -1001,19 +954,24 @@ export function createRealPolicyWorkbenchState() { return records.filter((record): record is NonNullable => record !== null) } + const compound = realDefinitions[policyKey as keyof typeof realDefinitions]?.compound + const compositeChildren = compound ? policiesStore.getPolicy(policyKey)?.meta?.compositeChildren ?? [] : [] + + const fetchCompoundMemberRecords = async (key: string): Promise => { + const [systemPolicy, groupPolicies, userPolicies] = await Promise.all([ + fetchSystemPolicySafe(key), + fetchPersistedGroupRulesForKey(key), + fetchPersistedUserRulesForKey(key), + ]) + + return { policyKey: key, systemPolicy, groupPolicies, userPolicies } + } + const hydratedPayload = await Promise.all([ fetchSystemPolicySafe(policyKey), fetchPersistedGroupRulesForKey(policyKey), fetchPersistedUserRulesForKey(policyKey), - shouldMergeRequestExpiration ? fetchSystemPolicySafe(REQUEST_EXPIRATION_RENEWAL_KEY) : Promise.resolve(null), - shouldMergeRequestExpiration ? fetchPersistedGroupRulesForKey(REQUEST_EXPIRATION_RENEWAL_KEY) : Promise.resolve([]), - shouldMergeRequestExpiration ? fetchPersistedUserRulesForKey(REQUEST_EXPIRATION_RENEWAL_KEY) : Promise.resolve([]), - shouldMergeSigningExecution ? fetchSystemPolicySafe(SIGNING_EXECUTION_WORKER_KEY) : Promise.resolve(null), - shouldMergeSigningExecution ? fetchPersistedGroupRulesForKey(SIGNING_EXECUTION_WORKER_KEY) : Promise.resolve([]), - shouldMergeSigningExecution ? fetchPersistedUserRulesForKey(SIGNING_EXECUTION_WORKER_KEY) : Promise.resolve([]), - shouldMergeSignatureStamp ? fetchSystemPolicySafe(COLLECT_METADATA_POLICY_KEY) : Promise.resolve(null), - shouldMergeSignatureStamp ? fetchPersistedGroupRulesForKey(COLLECT_METADATA_POLICY_KEY) : Promise.resolve([]), - shouldMergeSignatureStamp ? fetchPersistedUserRulesForKey(COLLECT_METADATA_POLICY_KEY) : Promise.resolve([]), + Promise.all(compositeChildren.map(fetchCompoundMemberRecords)), ]).catch((error) => { logger.debug('Could not hydrate persisted policy rules', { error, @@ -1032,37 +990,20 @@ export function createRealPolicyWorkbenchState() { return } - const [persistedSystemPolicy, persistedGroupPolicies, persistedUserPolicies, renewalSystemPolicy, renewalGroupPolicies, renewalUserPolicies, workerSystemPolicy, workerGroupPolicies, workerUserPolicies, collectMetadataSystemPolicy, collectMetadataGroupPolicies, collectMetadataUserPolicies] = hydratedPayload + const [persistedSystemPolicy, persistedGroupPolicies, persistedUserPolicies, childRecords] = hydratedPayload - const compoundHydration = hydrateCompoundPolicyRules({ - policyKey, - persistedSystemPolicy, - companionSystemPolicy: shouldMergeRequestExpiration - ? renewalSystemPolicy - : shouldMergeSigningExecution - ? workerSystemPolicy - : shouldMergeSignatureStamp - ? collectMetadataSystemPolicy - : null, - persistedGroupPolicies, - companionGroupPolicies: shouldMergeRequestExpiration - ? renewalGroupPolicies - : shouldMergeSigningExecution - ? workerGroupPolicies - : shouldMergeSignatureStamp - ? collectMetadataGroupPolicies - : [], - persistedUserPolicies, - companionUserPolicies: shouldMergeRequestExpiration - ? renewalUserPolicies - : shouldMergeSigningExecution - ? workerUserPolicies - : shouldMergeSignatureStamp - ? collectMetadataUserPolicies - : [], - }) + if (compound && compositeChildren.length > 0) { + const compoundHydration = hydrateCompoundPolicyRules({ + parent: { + policyKey, + systemPolicy: persistedSystemPolicy, + groupPolicies: persistedGroupPolicies, + userPolicies: persistedUserPolicies, + }, + children: childRecords, + compound, + }) - if (compoundHydration) { explicitSystemRule.value = compoundHydration.explicitSystemRule commitHydratedRules(policyKey, compoundHydration.groupRules, compoundHydration.userRules) finishHydration(currentRequestId, policyKey) @@ -1548,7 +1489,8 @@ export function createRealPolicyWorkbenchState() { targetIds, allowChildOverride, policiesStore, - collectMetadataEffectiveValue: policiesStore.getPolicy(COLLECT_METADATA_POLICY_KEY)?.effectiveValue, + compound: activeDefinition.value.compound, + compositeChildren: policiesStore.getPolicy(policyKey)?.meta?.compositeChildren, }) const savedValue = compoundSaveResult.handled ? compoundSaveResult.savedValue : value @@ -1556,8 +1498,10 @@ export function createRealPolicyWorkbenchState() { await policiesStore.saveSystemPolicy(policyKey, value, allowChildOverride) } if (viewMode.value === 'system-admin' && isScopeSupported('user')) { - const clearedCompoundPreferences = await clearCompoundUserPreferences(policyKey, policiesStore) - if (!clearedCompoundPreferences) { + const compoundPolicyKeys = resolveCompoundPolicyKeys(policyKey) + if (compoundPolicyKeys.length > 0) { + await clearCompoundUserPreferences(compoundPolicyKeys, policiesStore) + } else { await policiesStore.clearUserPreference(policyKey) } } @@ -1618,7 +1562,8 @@ export function createRealPolicyWorkbenchState() { targetIds, allowChildOverride, policiesStore, - collectMetadataEffectiveValue: policiesStore.getPolicy(COLLECT_METADATA_POLICY_KEY)?.effectiveValue, + compound: activeDefinition.value.compound, + compositeChildren: policiesStore.getPolicy(policyKey)?.meta?.compositeChildren, }) const savedValue = compoundSaveResult.handled ? compoundSaveResult.savedValue : value @@ -1651,7 +1596,8 @@ export function createRealPolicyWorkbenchState() { targetIds, allowChildOverride, policiesStore, - collectMetadataEffectiveValue: policiesStore.getPolicy(COLLECT_METADATA_POLICY_KEY)?.effectiveValue, + compound: activeDefinition.value.compound, + compositeChildren: policiesStore.getPolicy(policyKey)?.meta?.compositeChildren, }) const savedValue = compoundSaveResult.handled ? compoundSaveResult.savedValue : value @@ -1703,9 +1649,7 @@ export function createRealPolicyWorkbenchState() { cancelPendingHydration() const policyKey = activeDefinition.value.key - const isRequestExpiration = isRequestExpirationPolicyKey(policyKey) - const isUnifiedSigningExecution = isUnifiedSigningExecutionPolicyKey(policyKey) - const isSignatureStamp = isSignatureStampPolicyKey(policyKey) + const compoundPolicyKeys = resolveCompoundPolicyKeys(policyKey) const inheritedSystemRuleId = inheritedSystemRule.value?.id const shouldCloseSystemEditor = editorMode.value === 'edit' && editorDraft.value?.scope === 'system' const shouldCloseGroupEditor = editorMode.value === 'edit' && editorDraft.value?.scope === 'group' @@ -1713,63 +1657,77 @@ export function createRealPolicyWorkbenchState() { let shouldRefreshPolicies = false let shouldCloseEditor = false - for (const ruleId of uniqueRuleIds) { - if (ruleId === 'system-default' || (inheritedSystemRuleId !== null && ruleId === inheritedSystemRuleId)) { - const clearedCompoundRule = await clearCompoundPolicyTarget('system', policyKey, undefined, policiesStore) - if (!clearedCompoundRule) { - await policiesStore.saveSystemPolicy(policyKey, null, false) + try { + for (const ruleId of uniqueRuleIds) { + if (ruleId === 'system-default' || (inheritedSystemRuleId !== null && ruleId === inheritedSystemRuleId)) { + if (compoundPolicyKeys.length > 0) { + await clearCompoundPolicyTarget('system', compoundPolicyKeys, undefined, policiesStore) + } else { + await policiesStore.saveSystemPolicy(policyKey, null, false) + } + explicitSystemRule.value = null + highlightedRuleId.value = null + shouldRefreshPolicies = true + shouldCloseEditor = shouldCloseEditor || shouldCloseSystemEditor + continue } - explicitSystemRule.value = null - highlightedRuleId.value = null - shouldRefreshPolicies = true - shouldCloseEditor = shouldCloseEditor || shouldCloseSystemEditor - continue - } - const groupIndex = groupRules.value.findIndex((rule) => rule.id === ruleId) - if (groupIndex >= 0) { - const currentGroupRule = groupRules.value[groupIndex] - const targetId = currentGroupRule?.targetId - if (targetId) { - if (!isInstanceAdmin && policyKey === REQUEST_SIGN_GROUPS_POLICY_KEY && currentGroupRule) { - const denyGroups = resolveDeniedRequestSignGroups(currentGroupRule.value) + const groupIndex = groupRules.value.findIndex((rule) => rule.id === ruleId) + if (groupIndex >= 0) { + const currentGroupRule = groupRules.value[groupIndex] + const targetId = currentGroupRule?.targetId + if (targetId) { + if (!isInstanceAdmin && policyKey === REQUEST_SIGN_GROUPS_POLICY_KEY && currentGroupRule) { + const denyGroups = resolveDeniedRequestSignGroups(currentGroupRule.value) + + if (denyGroups.length > 0) { + await policiesStore.clearGroupPolicy(targetId, policyKey) + groupRules.value.splice(groupIndex, 1) + highlightedRuleId.value = null + shouldRefreshPolicies = true + shouldCloseEditor = shouldCloseEditor || shouldCloseGroupEditor + continue + } + } - if (denyGroups.length > 0) { + if (compoundPolicyKeys.length > 0) { + await clearCompoundPolicyTarget('group', compoundPolicyKeys, targetId, policiesStore) + } else { await policiesStore.clearGroupPolicy(targetId, policyKey) - groupRules.value.splice(groupIndex, 1) - highlightedRuleId.value = null - shouldRefreshPolicies = true - shouldCloseEditor = shouldCloseEditor || shouldCloseGroupEditor - continue } } - - const clearedCompoundRule = await clearCompoundPolicyTarget('group', policyKey, targetId, policiesStore) - if (!clearedCompoundRule) { - await policiesStore.clearGroupPolicy(targetId, policyKey) - } + groupRules.value.splice(groupIndex, 1) + highlightedRuleId.value = null + shouldRefreshPolicies = true + shouldCloseEditor = shouldCloseEditor || shouldCloseGroupEditor + continue } - groupRules.value.splice(groupIndex, 1) - highlightedRuleId.value = null - shouldRefreshPolicies = true - shouldCloseEditor = shouldCloseEditor || shouldCloseGroupEditor - continue - } - const userIndex = userRules.value.findIndex((rule) => rule.id === ruleId) - if (userIndex >= 0) { - const targetId = userRules.value[userIndex]?.targetId - if (targetId) { - const clearedCompoundRule = await clearCompoundPolicyTarget('user', policyKey, targetId, policiesStore) - if (!clearedCompoundRule) { - await policiesStore.clearUserPolicyForUser(targetId, policyKey) + const userIndex = userRules.value.findIndex((rule) => rule.id === ruleId) + if (userIndex >= 0) { + const targetId = userRules.value[userIndex]?.targetId + if (targetId) { + if (compoundPolicyKeys.length > 0) { + await clearCompoundPolicyTarget('user', compoundPolicyKeys, targetId, policiesStore) + } else { + await policiesStore.clearUserPolicyForUser(targetId, policyKey) + } } + userRules.value.splice(userIndex, 1) + highlightedRuleId.value = null + shouldRefreshPolicies = true + shouldCloseEditor = shouldCloseEditor || shouldCloseUserEditor } - userRules.value.splice(userIndex, 1) - highlightedRuleId.value = null - shouldRefreshPolicies = true - shouldCloseEditor = shouldCloseEditor || shouldCloseUserEditor } + } catch (error) { + duplicateMessage.value = resolvePolicySaveErrorMessage(error) + logger.debug('Could not remove policy workbench rules', { + error, + policyKey, + }) + await policiesStore.fetchEffectivePolicies() + await hydratePersistedRules(policyKey) + throw error } if (shouldRefreshPolicies) { diff --git a/tests/php/Unit/Service/Policy/Provider/SignatureText/SignatureTextPolicyTest.php b/tests/php/Unit/Service/Policy/Provider/SignatureText/SignatureTextPolicyTest.php index 4da9f828c5..77fbefe049 100644 --- a/tests/php/Unit/Service/Policy/Provider/SignatureText/SignatureTextPolicyTest.php +++ b/tests/php/Unit/Service/Policy/Provider/SignatureText/SignatureTextPolicyTest.php @@ -11,6 +11,7 @@ use OCA\Libresign\Service\Policy\Model\ActorRole; use OCA\Libresign\Service\Policy\Model\PolicyContext; use OCA\Libresign\Service\Policy\Model\PolicyLayer; +use OCA\Libresign\Service\Policy\Provider\CollectMetadata\CollectMetadataPolicy; use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicy; use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicyValue; use OCP\IL10N; @@ -40,7 +41,7 @@ public function testConsolidatedPolicyExposesCanonicalDefaultInResolvedMeta(): v $this->assertSame($spec->defaultSystemValue(), $resolvedStateMeta['defaultSystemValue']); $this->assertSame(SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY, $spec->getAppConfigKey()); $this->assertSame(['system', 'group', 'user'], $spec->supportedScopes()); - $this->assertSame([], $spec->compositeChildren()); + $this->assertSame([CollectMetadataPolicy::KEY], $spec->compositeChildren()); } public function testConsolidatedPolicyPreservesDescriptionOnlyRenderMode(): void {