diff --git a/apps/client/app/features/knowledge/api/knowledgeApi.test.ts b/apps/client/app/features/knowledge/api/knowledgeApi.test.ts index d8d559448..7d679aaff 100644 --- a/apps/client/app/features/knowledge/api/knowledgeApi.test.ts +++ b/apps/client/app/features/knowledge/api/knowledgeApi.test.ts @@ -22,12 +22,17 @@ import { knowledgeApi, RAGAgentStreamEvent } from './knowledgeApi'; import { apiClient } from '@/lib/apiClient'; import { getStoredActiveOrganizationId } from '@/lib/activeOrganization'; -const streamResponse = (chunks: Array, status = 200): Response => { +const streamResponse = ( + chunks: Array, + status = 200, +): Response => { const encoder = new TextEncoder(); const body = new ReadableStream({ start(controller) { for (const chunk of chunks) { - controller.enqueue(typeof chunk === 'string' ? encoder.encode(chunk) : chunk); + controller.enqueue( + typeof chunk === 'string' ? encoder.encode(chunk) : chunk, + ); } controller.close(); }, @@ -51,7 +56,9 @@ afterEach(() => { describe('knowledgeApi.getProgressUrl', () => { it('includes the active organization for native EventSource authorization', () => { - vi.mocked(getStoredActiveOrganizationId).mockReturnValueOnce('org/with space'); + vi.mocked(getStoredActiveOrganizationId).mockReturnValueOnce( + 'org/with space', + ); expect(knowledgeApi.getProgressUrl('document-1')).toBe( 'http://localhost:8000/api/v1/rag/document/document-1/progress?organizationId=org%2Fwith%20space', @@ -128,7 +135,9 @@ describe('knowledgeApi.streamAgentAnswer', () => { ); vi.stubGlobal('fetch', fetchMock); - await knowledgeApi.streamAgentAnswer(payload, (event) => events.push(event)); + await knowledgeApi.streamAgentAnswer(payload, (event) => + events.push(event), + ); expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8000/api/v1/rag/agent/answer/stream', @@ -183,7 +192,9 @@ describe('knowledgeApi.streamAgentAnswer', () => { ), ); - await knowledgeApi.streamAgentAnswer(payload, (event) => events.push(event)); + await knowledgeApi.streamAgentAnswer(payload, (event) => + events.push(event), + ); expect(events).toEqual([ { @@ -219,8 +230,7 @@ describe('knowledgeApi.streamAgentAnswer', () => { it('flushes the decoder before parsing the final buffered event', async () => { const events: RAGAgentStreamEvent[] = []; const encoded = new TextEncoder().encode( - 'event: summary\n' + - 'data: {"answer_run_id":"run-1","message":"완료"}', + 'event: summary\n' + 'data: {"answer_run_id":"run-1","message":"완료"}', ); vi.stubGlobal( 'fetch', @@ -232,7 +242,9 @@ describe('knowledgeApi.streamAgentAnswer', () => { ), ); - await knowledgeApi.streamAgentAnswer(payload, (event) => events.push(event)); + await knowledgeApi.streamAgentAnswer(payload, (event) => + events.push(event), + ); expect(events).toEqual([ { @@ -256,7 +268,9 @@ describe('knowledgeApi.streamAgentAnswer', () => { ), ); - await knowledgeApi.streamAgentAnswer(payload, (event) => events.push(event)); + await knowledgeApi.streamAgentAnswer(payload, (event) => + events.push(event), + ); expect(events).toEqual([ { @@ -283,7 +297,9 @@ describe('knowledgeApi.streamAgentAnswer', () => { ), ); - await knowledgeApi.streamAgentAnswer(payload, (event) => events.push(event)); + await knowledgeApi.streamAgentAnswer(payload, (event) => + events.push(event), + ); expect(events).toEqual([ { @@ -296,16 +312,20 @@ describe('knowledgeApi.streamAgentAnswer', () => { it('uses sanitized HTTP error messages from the API envelope', async () => { vi.stubGlobal( 'fetch', - vi.fn(async () => - new Response( - JSON.stringify({ - error: { code: 'permission.denied', message: 'Permission denied.' }, - }), - { - status: 403, - headers: { 'Content-Type': 'application/json' }, - }, - ), + vi.fn( + async () => + new Response( + JSON.stringify({ + error: { + code: 'permission.denied', + message: 'Permission denied.', + }, + }), + { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }, + ), ), ); @@ -514,16 +534,83 @@ describe('knowledgeApi collection management', () => { ); }); + it('restores an archived Collection through the lifecycle endpoint', async () => { + vi.mocked(apiClient.post).mockResolvedValueOnce({ data: undefined }); + + await knowledgeApi.restoreKnowledgeCollection('collection-1'); + + expect(apiClient.post).toHaveBeenCalledWith( + '/knowledge/collections/collection-1/restore', + ); + }); + + it('sends the exact item set with the current order revision', async () => { + vi.mocked(apiClient.patch).mockResolvedValueOnce({ + data: { + items: [], + order_revision: `ord_v1_${'a'.repeat(64)}`, + reorder_supported: true, + }, + }); + + await knowledgeApi.reorderKnowledgeCollectionItems( + 'collection-1', + [{ item_id: 'item-1', rank: 0 }], + `ord_v1_${'0'.repeat(64)}`, + true, + ); + + expect(apiClient.patch).toHaveBeenCalledWith( + '/knowledge/collections/collection-1/items/reorder', + { + items: [{ item_id: 'item-1', rank: 0 }], + expected_order_revision: `ord_v1_${'0'.repeat(64)}`, + acknowledged_public_runtime_exposure: true, + }, + ); + }); + + it('loads a bounded Collection delegation subject page', async () => { + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { subjects: [], next_cursor: 'next-page' }, + }); + + await knowledgeApi.getKnowledgeCollectionDelegationSubjects( + 'collection-1', + { + subject_type: 'user', + query: 'Alpha', + cursor: 'current-page', + limit: 25, + }, + ); + + expect(apiClient.get).toHaveBeenCalledWith( + '/knowledge/collections/collection-1/delegation-subjects', + { + params: { + subject_type: 'user', + query: 'Alpha', + cursor: 'current-page', + limit: 25, + }, + }, + ); + }); + it('applies Collection role bundles through the transactional endpoint', async () => { vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { permissions: [] }, }); - await knowledgeApi.grantKnowledgeCollectionPermissionBundle('collection-1', { - subject_type: 'team', - subject_id: 'team-1', - role_bundle: 'workflow_router', - }); + await knowledgeApi.grantKnowledgeCollectionPermissionBundle( + 'collection-1', + { + subject_type: 'team', + subject_id: 'team-1', + role_bundle: 'workflow_router', + }, + ); expect(apiClient.post).toHaveBeenCalledWith( '/knowledge/collections/collection-1/permissions/bundles', @@ -535,6 +622,58 @@ describe('knowledgeApi collection management', () => { ); }); + it('revokes one bundle and applies a multi-Collection bundle atomically', async () => { + vi.mocked(apiClient.post) + .mockResolvedValueOnce({ data: undefined }) + .mockResolvedValueOnce({ + data: { + operation: 'grant', + subject_type: 'team', + role_bundle: 'viewer', + target_count_bucket: '2-10', + changed_count_bucket: '2-10', + unchanged_count_bucket: '0', + }, + }); + + await knowledgeApi.revokeKnowledgeCollectionPermissionBundle( + 'collection-1', + { + subject_type: 'team', + subject_id: 'team-1', + role_bundle: 'viewer', + }, + ); + await knowledgeApi.mutateKnowledgeCollectionPermissionBundles({ + collection_ids: ['collection-1', 'collection-2'], + operation: 'grant', + subject_type: 'team', + subject_id: 'team-1', + role_bundle: 'viewer', + }); + + expect(apiClient.post).toHaveBeenNthCalledWith( + 1, + '/knowledge/collections/collection-1/permissions/bundles/revoke', + { + subject_type: 'team', + subject_id: 'team-1', + role_bundle: 'viewer', + }, + ); + expect(apiClient.post).toHaveBeenNthCalledWith( + 2, + '/knowledge/collection-permissions/bulk-bundles', + { + collection_ids: ['collection-1', 'collection-2'], + operation: 'grant', + subject_type: 'team', + subject_id: 'team-1', + role_bundle: 'viewer', + }, + ); + }); + it('sends public membership acknowledgement when unlinking', async () => { vi.mocked(apiClient.delete).mockResolvedValueOnce({ data: undefined }); diff --git a/apps/client/app/features/knowledge/api/knowledgeApi.ts b/apps/client/app/features/knowledge/api/knowledgeApi.ts index 2eb88201c..5efa2a3a3 100644 --- a/apps/client/app/features/knowledge/api/knowledgeApi.ts +++ b/apps/client/app/features/knowledge/api/knowledgeApi.ts @@ -20,6 +20,7 @@ import { KnowledgeCollectionLinkCandidatesResponse, KnowledgeCollectionPermissionResponse, KnowledgeCollectionPermissionsResponse, + KnowledgeCollectionPermissionBulkBundleResponse, KnowledgeCollectionVisibility, KnowledgeCollectionVisibilityResponse, KnowledgeDelegationSubjectsResponse, @@ -184,6 +185,7 @@ export type { KnowledgeCollectionLinkCandidatesResponse, KnowledgeCollectionPermissionResponse, KnowledgeCollectionPermissionsResponse, + KnowledgeCollectionPermissionBulkBundleResponse, KnowledgeCollectionVisibility, KnowledgeCollectionVisibilityResponse, KnowledgeDelegationSubjectsResponse, @@ -484,11 +486,17 @@ export const knowledgeApi = { return response.data; }, - getKnowledgeDomainDelegationSubjects: - async (): Promise => { - const response = await api.get('/knowledge/domain-delegation-subjects'); - return response.data; - }, + getKnowledgeDomainDelegationSubjects: async (params: { + subject_type: 'team' | 'user'; + query?: string; + cursor?: string; + limit?: number; + }): Promise => { + const response = await api.get('/knowledge/domain-delegation-subjects', { + params, + }); + return response.data; + }, grantKnowledgeDomainPermission: async (data: { subject_type: 'team' | 'user'; @@ -530,6 +538,10 @@ export const knowledgeApi = { await api.delete(`/knowledge/collections/${id}`); }, + restoreKnowledgeCollection: async (id: string): Promise => { + await api.post(`/knowledge/collections/${id}/restore`); + }, + getKnowledgeCollectionItems: async ( id: string, ): Promise => { @@ -541,7 +553,6 @@ export const knowledgeApi = { id: string, data: { knowledge_base_id: string; - rank?: number; acknowledged_public_runtime_exposure?: boolean; }, ): Promise => { @@ -556,8 +567,7 @@ export const knowledgeApi = { ): Promise => { await api.delete(`/knowledge/collections/${id}/items/${itemId}`, { params: { - acknowledged_public_runtime_exposure: - acknowledgedPublicRuntimeExposure, + acknowledged_public_runtime_exposure: acknowledgedPublicRuntimeExposure, }, }); }, @@ -565,14 +575,15 @@ export const knowledgeApi = { reorderKnowledgeCollectionItems: async ( id: string, items: { item_id: string; rank: number }[], + expectedOrderRevision: string, acknowledgedPublicRuntimeExposure = false, ): Promise => { const response = await api.patch( `/knowledge/collections/${id}/items/reorder`, { items, - acknowledged_public_runtime_exposure: - acknowledgedPublicRuntimeExposure, + expected_order_revision: expectedOrderRevision, + acknowledged_public_runtime_exposure: acknowledgedPublicRuntimeExposure, }, ); return response.data; @@ -596,9 +607,16 @@ export const knowledgeApi = { getKnowledgeCollectionDelegationSubjects: async ( id: string, + params: { + subject_type: 'team' | 'user'; + query?: string; + cursor?: string; + limit?: number; + }, ): Promise => { const response = await api.get( `/knowledge/collections/${id}/delegation-subjects`, + { params }, ); return response.data; }, @@ -633,11 +651,41 @@ export const knowledgeApi = { return response.data; }, + revokeKnowledgeCollectionPermissionBundle: async ( + id: string, + data: { + subject_type: 'team' | 'user'; + subject_id: string; + role_bundle: KnowledgeCollectionRoleBundle; + }, + ): Promise => { + await api.post( + `/knowledge/collections/${id}/permissions/bundles/revoke`, + data, + ); + }, + + mutateKnowledgeCollectionPermissionBundles: async (data: { + collection_ids: string[]; + operation: 'grant' | 'revoke'; + subject_type: 'team' | 'user'; + subject_id: string; + role_bundle: KnowledgeCollectionRoleBundle; + }): Promise => { + const response = await api.post( + '/knowledge/collection-permissions/bulk-bundles', + data, + ); + return response.data; + }, + revokeKnowledgeCollectionPermission: async ( id: string, permissionId: string, ): Promise => { - await api.delete(`/knowledge/collections/${id}/permissions/${permissionId}`); + await api.delete( + `/knowledge/collections/${id}/permissions/${permissionId}`, + ); }, updateKnowledgeCollectionVisibility: async ( diff --git a/apps/client/app/features/knowledge/components/knowledge-collection-manager-panels.tsx b/apps/client/app/features/knowledge/components/knowledge-collection-manager-panels.tsx index b00009c2f..6895e6211 100644 --- a/apps/client/app/features/knowledge/components/knowledge-collection-manager-panels.tsx +++ b/apps/client/app/features/knowledge/components/knowledge-collection-manager-panels.tsx @@ -1,8 +1,10 @@ 'use client'; -import type { Dispatch, SetStateAction } from 'react'; +import { useEffect, useState, type Dispatch, type SetStateAction } from 'react'; import { Archive, + ArrowDown, + ArrowUp, Check, Eye, EyeOff, @@ -10,12 +12,14 @@ import { Link2, Loader2, Plus, + RotateCcw, + Search, ShieldAlert, Trash2, Users, } from 'lucide-react'; import type { - KnowledgeCollectionItemResponse, + KnowledgeCollectionItemsResponse, KnowledgeCollectionLinkCandidate, KnowledgeCollectionPermissionResponse, KnowledgeCollectionRoleBundle, @@ -70,30 +74,55 @@ const collectionRoleBundles: Array<{ { value: 'sync_operator', label: 'Sync Operator (read + sync)' }, ]; +const isCollectionOrderConflict = (error: unknown): boolean => { + if (typeof error !== 'object' || error === null || !('response' in error)) { + return false; + } + return ( + ( + error as { + response?: { data?: { error?: { details?: { reason?: string } } } }; + } + ).response?.data?.error?.details?.reason === 'collection_order_stale' + ); +}; + type DomainDelegationPanelProps = { form: DomainGrantFormState; + hasSubjectLoadError: boolean; + isSubjectLoading: boolean; isSaving: boolean; permissions: KnowledgeDomainPermissionListResponse['permissions']; + subjectQuery: string; subjects: KnowledgeDelegationSubjectsResponse; onGrant: () => void; + onLoadMoreSubjects: () => void; + onRetrySubjects: () => void; onRevoke: ( subjectType: 'team' | 'user', subjectId: string, action: KnowledgeDomainAction, ) => void; + onSubjectQueryChange: (query: string) => void; setForm: Dispatch>; }; export function DomainDelegationPanel({ form, + hasSubjectLoadError, + isSubjectLoading, isSaving, permissions, + subjectQuery, subjects, onGrant, + onLoadMoreSubjects, + onRetrySubjects, onRevoke, + onSubjectQueryChange, setForm, }: DomainDelegationPanelProps) { - const subjectOptions = form.subject_type === 'team' ? subjects.teams : subjects.users; + const subjectOptions = subjects.subjects ?? []; return (
@@ -121,20 +150,57 @@ export function DomainDelegationPanel({ - onSubjectQueryChange(event.target.value)} + maxLength={100} + placeholder="이름으로 검색" + className="min-w-0 flex-1 bg-transparent text-sm outline-none" + /> + + + {subjectOptions.map((subject) => ( + + ))} + + {subjects.next_cursor && ( + + )} + {hasSubjectLoadError && ( + + )} +
onToggleBulkCollection(collection.id)} + disabled={!collection.can_manage && !canDelegatePermissions} + aria-label={`${collection.name} bulk 권한 대상 선택`} + className="mt-1 disabled:cursor-not-allowed" + /> + + ))} )} @@ -399,15 +522,29 @@ type CollectionDetailPanelProps = { collection: KnowledgeCollectionResponse | null; editForm: CollectionFormState; grantForm: GrantFormState; + hasSubjectLoadError: boolean; isDetailLoading: boolean; + isSubjectLoading: boolean; isSaving: boolean; - items: KnowledgeCollectionItemResponse[]; + itemData: KnowledgeCollectionItemsResponse | null; permissions: KnowledgeCollectionPermissionResponse[]; + selectedBulkCount: number; + subjectQuery: string; subjects: KnowledgeDelegationSubjectsResponse; onArchive: () => void; + onBulkPermission: (operation: 'grant' | 'revoke') => void; onGrantPermission: () => void; onLinkCandidate: (candidateId: string) => void; + onLoadMoreSubjects: () => void; + onRetrySubjects: () => void; + onReorderItems: ( + items: { item_id: string; rank: number }[], + expectedOrderRevision: string, + ) => Promise; onRevokePermission: (permissionId: string) => void; + onRevokePermissionBundle: () => void; + onRestore: () => void; + onSubjectQueryChange: (query: string) => void; onUnlinkItem: (itemId: string) => void; onUpdateCollection: () => void; onUpdateVisibility: (visibility: KnowledgeCollectionVisibility) => void; @@ -423,15 +560,26 @@ export function CollectionDetailPanel({ collection, editForm, grantForm, + hasSubjectLoadError, isDetailLoading, + isSubjectLoading, isSaving, - items, + itemData, permissions, + selectedBulkCount, + subjectQuery, subjects, onArchive, + onBulkPermission, onGrantPermission, onLinkCandidate, + onLoadMoreSubjects, + onRetrySubjects, + onReorderItems, onRevokePermission, + onRevokePermissionBundle, + onRestore, + onSubjectQueryChange, onUnlinkItem, onUpdateCollection, onUpdateVisibility, @@ -451,9 +599,11 @@ export function CollectionDetailPanel({
@@ -483,8 +633,9 @@ export function CollectionDetailPanel({ canManagePublicMembership={capabilities.can_change_public_visibility} isDetailLoading={isDetailLoading} isSaving={isSaving} - items={items} + itemData={itemData} onLinkCandidate={onLinkCandidate} + onReorderItems={onReorderItems} onUnlinkItem={onUnlinkItem} /> @@ -492,11 +643,20 @@ export function CollectionDetailPanel({ canDelegatePermissions={capabilities.can_delegate_permissions} collection={collection} grantForm={grantForm} + hasSubjectLoadError={hasSubjectLoadError} + isSubjectLoading={isSubjectLoading} isSaving={isSaving} permissions={permissions} + selectedBulkCount={selectedBulkCount} + subjectQuery={subjectQuery} subjects={subjects} + onBulkPermission={onBulkPermission} onGrantPermission={onGrantPermission} + onLoadMoreSubjects={onLoadMoreSubjects} + onRetrySubjects={onRetrySubjects} onRevokePermission={onRevokePermission} + onRevokePermissionBundle={onRevokePermissionBundle} + onSubjectQueryChange={onSubjectQueryChange} setGrantForm={setGrantForm} /> @@ -507,16 +667,20 @@ export function CollectionDetailPanel({ type CollectionHeaderProps = { canArchive: boolean; + canRestore: boolean; collection: KnowledgeCollectionResponse; isSaving: boolean; onArchive: () => void; + onRestore: () => void; }; function CollectionHeader({ canArchive, + canRestore, collection, isSaving, onArchive, + onRestore, }: CollectionHeaderProps) { return (
@@ -539,15 +703,32 @@ function CollectionHeader({
- + {collection.lifecycle_state === 'archived' ? ( + + ) : ( + + )} ); } @@ -570,7 +751,9 @@ function CollectionInfoPanel({ setEditForm, }: CollectionInfoPanelProps) { const isEditable = - (collection.can_manage || canManageCatalog) && !collection.is_system_managed; + collection.lifecycle_state === 'active' && + (collection.can_manage || canManageCatalog) && + !collection.is_system_managed; return (
@@ -683,7 +866,7 @@ function CollectionVisibilityPanel({

{collection.visibility === 'public' ? 'public' : 'private'}

- {canChangeVisibility ? ( + {canChangeVisibility && collection.lifecycle_state === 'active' ? ( <>
@@ -737,8 +920,12 @@ type CollectionItemsPanelProps = { collection: KnowledgeCollectionResponse; isDetailLoading: boolean; isSaving: boolean; - items: KnowledgeCollectionItemResponse[]; + itemData: KnowledgeCollectionItemsResponse | null; onLinkCandidate: (candidateId: string) => void; + onReorderItems: ( + items: { item_id: string; rank: number }[], + expectedOrderRevision: string, + ) => Promise; onUnlinkItem: (itemId: string) => void; }; @@ -750,14 +937,56 @@ function CollectionItemsPanel({ collection, isDetailLoading, isSaving, - items, + itemData, onLinkCandidate, + onReorderItems, onUnlinkItem, }: CollectionItemsPanelProps) { + const serverItems = itemData?.items ?? []; + const [draftItems, setDraftItems] = useState(serverItems); + const [orderConflict, setOrderConflict] = useState(false); + + useEffect(() => { + setDraftItems(itemData?.items ?? []); + setOrderConflict(false); + }, [itemData]); + const canMutateMembership = - collection.visibility === 'public' - ? canManagePublicMembership && acknowledgePublic - : collection.can_manage || canManageCatalog; + collection.lifecycle_state !== 'active' + ? false + : collection.visibility === 'public' + ? canManagePublicMembership && acknowledgePublic + : collection.can_manage || canManageCatalog; + const isOrderDirty = draftItems.some( + (item, index) => serverItems[index]?.item_id !== item.item_id, + ); + const canReorder = + canMutateMembership && + Boolean(itemData?.reorder_supported) && + draftItems.length > 1; + + const moveItem = (index: number, direction: -1 | 1) => { + const nextIndex = index + direction; + if (!canReorder || nextIndex < 0 || nextIndex >= draftItems.length) return; + setDraftItems((current) => { + const next = [...current]; + [next[index], next[nextIndex]] = [next[nextIndex], next[index]]; + return next; + }); + setOrderConflict(false); + }; + + const saveOrder = async () => { + if (!itemData || !isOrderDirty) return; + try { + await onReorderItems( + draftItems.map((item, rank) => ({ item_id: item.item_id, rank })), + itemData.order_revision, + ); + } catch (error) { + setOrderConflict(isCollectionOrderConflict(error)); + } + }; return (
@@ -767,12 +996,46 @@ function CollectionItemsPanel({ )}
+ {!itemData?.reorder_supported && itemData?.safe_reason_code && ( +

+ 항목이 많아 이 화면에서는 순서를 변경할 수 없습니다. +

+ )} + {orderConflict && ( +

+ 다른 변경이 먼저 저장되었습니다. Collection을 다시 선택해 최신 순서를 + 불러온 뒤 다시 시도하세요. +

+ )} + {isOrderDirty && ( +
+ + +
+ )}
- {items.length === 0 ? ( + {draftItems.length === 0 ? (

연결된 KB가 없습니다.

) : ( - items.map((item) => ( + draftItems.map((item, index) => (
- +
+ + + +
)) )} @@ -836,11 +1119,20 @@ type CollectionPermissionsPanelProps = { canDelegatePermissions: boolean; collection: KnowledgeCollectionResponse; grantForm: GrantFormState; + hasSubjectLoadError: boolean; + isSubjectLoading: boolean; isSaving: boolean; permissions: KnowledgeCollectionPermissionResponse[]; + selectedBulkCount: number; + subjectQuery: string; subjects: KnowledgeDelegationSubjectsResponse; + onBulkPermission: (operation: 'grant' | 'revoke') => void; onGrantPermission: () => void; + onLoadMoreSubjects: () => void; + onRetrySubjects: () => void; onRevokePermission: (permissionId: string) => void; + onRevokePermissionBundle: () => void; + onSubjectQueryChange: (query: string) => void; setGrantForm: Dispatch>; }; @@ -848,16 +1140,54 @@ function CollectionPermissionsPanel({ canDelegatePermissions, collection, grantForm, + hasSubjectLoadError, + isSubjectLoading, isSaving, permissions, + selectedBulkCount, + subjectQuery, subjects, + onBulkPermission, onGrantPermission, + onLoadMoreSubjects, + onRetrySubjects, onRevokePermission, + onRevokePermissionBundle, + onSubjectQueryChange, setGrantForm, }: CollectionPermissionsPanelProps) { const canManagePermissions = collection.can_manage || canDelegatePermissions; - const subjectOptions = - grantForm.subject_type === 'team' ? subjects.teams : subjects.users; + const activeSubjectOptions = subjects.subjects ?? []; + const activeSubjectIds = new Set( + activeSubjectOptions.map((subject) => subject.subject_id), + ); + const existingSubjectOptions = permissions + .filter( + (permission, index, rows) => + permission.subject_type === grantForm.subject_type && + !activeSubjectIds.has(permission.subject_id) && + rows.findIndex( + (candidate) => + candidate.subject_type === permission.subject_type && + candidate.subject_id === permission.subject_id, + ) === index, + ) + .map((permission) => ({ + subject_type: permission.subject_type, + subject_id: permission.subject_id, + subject_safe_label: + permission.subject_safe_label || + (permission.subject_type === 'team' ? 'Team' : 'User'), + existingPermissionOnly: true, + })); + const subjectOptions = [ + ...activeSubjectOptions.map((subject) => ({ + ...subject, + existingPermissionOnly: false, + })), + ...existingSubjectOptions, + ]; + const selectedSubjectIsActive = activeSubjectIds.has(grantForm.subject_id); return (
@@ -865,7 +1195,7 @@ function CollectionPermissionsPanel({

권한

{canManagePermissions ? ( -
+
- onSubjectQueryChange(event.target.value)} + maxLength={100} + placeholder="이름으로 검색" + className="min-w-0 flex-1 text-sm outline-none" + /> + + + {subjectOptions.map((subject) => ( + + ))} + + {subjects.next_cursor && ( + + )} + {hasSubjectLoadError && ( + + )} +
- +
+ + +
) : (

- 권한 관리는 collection.manage 또는 Knowledge permission_delegate가 필요합니다. + 권한 관리는 collection.manage 또는 Knowledge permission_delegate가 + 필요합니다.

)} + {canManagePermissions && selectedBulkCount > 0 && ( +
+

+ 선택한 Collection {selectedBulkCount}개에 같은 대상과 bundle을 + all-or-nothing으로 적용합니다. +

+
+ + +
+
+ )} {permissions.length === 0 ? (

표시할 권한이 없습니다.

) : ( @@ -938,7 +1349,8 @@ function CollectionPermissionsPanel({ >
- {permission.subject_safe_label || permission.subject_id} + {permission.subject_safe_label || + (permission.subject_type === 'team' ? 'Team' : 'User')} {permission.subject_type} · {permission.permission_action} diff --git a/apps/client/app/features/knowledge/components/knowledge-collection-manager.test.tsx b/apps/client/app/features/knowledge/components/knowledge-collection-manager.test.tsx index 4edb04f9d..2eeced31f 100644 --- a/apps/client/app/features/knowledge/components/knowledge-collection-manager.test.tsx +++ b/apps/client/app/features/knowledge/components/knowledge-collection-manager.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import KnowledgeCollectionManager from './knowledge-collection-manager'; +import { DomainDelegationPanel } from './knowledge-collection-manager-panels'; const knowledgeApiMock = vi.hoisted(() => ({ getKnowledgeCollectionsResponse: vi.fn(), @@ -14,10 +15,14 @@ const knowledgeApiMock = vi.hoisted(() => ({ createKnowledgeCollection: vi.fn(), updateKnowledgeCollection: vi.fn(), archiveKnowledgeCollection: vi.fn(), + restoreKnowledgeCollection: vi.fn(), linkKnowledgeCollectionItem: vi.fn(), unlinkKnowledgeCollectionItem: vi.fn(), grantKnowledgeCollectionPermission: vi.fn(), grantKnowledgeCollectionPermissionBundle: vi.fn(), + revokeKnowledgeCollectionPermissionBundle: vi.fn(), + mutateKnowledgeCollectionPermissionBundles: vi.fn(), + reorderKnowledgeCollectionItems: vi.fn(), grantKnowledgeDomainPermission: vi.fn(), revokeKnowledgeDomainPermission: vi.fn(), revokeKnowledgeCollectionPermission: vi.fn(), @@ -33,8 +38,40 @@ describe('KnowledgeCollectionManager', () => { vi.resetAllMocks(); }); + it('offers an explicit retry after delegation subject lookup fails', () => { + const retry = vi.fn(); + + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: '대상 조회 다시 시도' }), + ); + + expect(retry).toHaveBeenCalledTimes(1); + }); + it('keeps read-only item view available without calling manage-only APIs', async () => { - knowledgeApiMock.getKnowledgeDomainCapabilities.mockResolvedValueOnce({ + knowledgeApiMock.getKnowledgeDomainCapabilities.mockResolvedValue({ actions: [], can_manage_domain_permissions: false, can_create_collection: false, @@ -43,7 +80,7 @@ describe('KnowledgeCollectionManager', () => { can_manage_sync: false, can_change_public_visibility: false, }); - knowledgeApiMock.getKnowledgeCollectionsResponse.mockResolvedValueOnce({ + knowledgeApiMock.getKnowledgeCollectionsResponse.mockResolvedValue({ collections: [ { id: 'collection-1', @@ -81,20 +118,25 @@ describe('KnowledgeCollectionManager', () => { can_use_kb: true, }, ], + order_revision: `ord_v1_${'1'.repeat(64)}`, + reorder_supported: true, + safe_reason_code: null, }); render(); expect(await screen.findByText('휴가 정책')).toBeInTheDocument(); - expect( - screen.getByRole('textbox', { name: '안전 표시 이름' }), - ).toHaveValue('인사 정책'); + expect(screen.getByRole('textbox', { name: '안전 표시 이름' })).toHaveValue( + '인사 정책', + ); expect( screen.getByRole('textbox', { name: '안전 표시 이름' }), ).toBeDisabled(); expect(screen.queryByText('Collection 생성')).not.toBeInTheDocument(); expect( - screen.getByText('공개 상태 전환은 organization manager만 수행할 수 있습니다.'), + screen.getByText( + '공개 상태 전환은 organization manager만 수행할 수 있습니다.', + ), ).toBeInTheDocument(); expect( screen.getByText( @@ -104,8 +146,12 @@ describe('KnowledgeCollectionManager', () => { expect(screen.queryByText('부여')).not.toBeInTheDocument(); expect(screen.getByLabelText('KB 연결 해제')).toBeDisabled(); await waitFor(() => { - expect(knowledgeApiMock.getKnowledgeCollectionLinkCandidates).not.toHaveBeenCalled(); - expect(knowledgeApiMock.getKnowledgeCollectionPermissions).not.toHaveBeenCalled(); + expect( + knowledgeApiMock.getKnowledgeCollectionLinkCandidates, + ).not.toHaveBeenCalled(); + expect( + knowledgeApiMock.getKnowledgeCollectionPermissions, + ).not.toHaveBeenCalled(); expect( knowledgeApiMock.getKnowledgeCollectionDelegationSubjects, ).not.toHaveBeenCalled(); @@ -113,7 +159,7 @@ describe('KnowledgeCollectionManager', () => { }); it('uses delegated domain capability for collection creation and Team-first bundles', async () => { - knowledgeApiMock.getKnowledgeDomainCapabilities.mockResolvedValueOnce({ + knowledgeApiMock.getKnowledgeDomainCapabilities.mockResolvedValue({ actions: ['catalog_manage', 'permission_delegate', 'lifecycle_manage'], can_manage_domain_permissions: false, can_create_collection: true, @@ -122,7 +168,7 @@ describe('KnowledgeCollectionManager', () => { can_manage_sync: false, can_change_public_visibility: false, }); - knowledgeApiMock.getKnowledgeCollectionsResponse.mockResolvedValueOnce({ + knowledgeApiMock.getKnowledgeCollectionsResponse.mockResolvedValue({ collections: [ { id: 'collection-1', @@ -147,26 +193,35 @@ describe('KnowledgeCollectionManager', () => { can_create_collection: false, can_change_public_visibility: false, }); - knowledgeApiMock.getKnowledgeCollectionItems.mockResolvedValue({ items: [] }); + knowledgeApiMock.getKnowledgeCollectionItems.mockResolvedValue({ + items: [], + order_revision: `ord_v1_${'2'.repeat(64)}`, + reorder_supported: true, + safe_reason_code: null, + }); knowledgeApiMock.getKnowledgeCollectionLinkCandidates.mockResolvedValue({ candidates: [], }); knowledgeApiMock.getKnowledgeCollectionPermissions.mockResolvedValue({ permissions: [], }); - knowledgeApiMock.getKnowledgeCollectionDelegationSubjects.mockResolvedValue({ - teams: [ - { - subject_type: 'team', - subject_id: 'team-1', - subject_safe_label: 'Knowledge 전담 Team', - }, - ], - users: [], - }); - knowledgeApiMock.grantKnowledgeCollectionPermissionBundle.mockResolvedValueOnce({ - permissions: [], - }); + knowledgeApiMock.getKnowledgeCollectionDelegationSubjects.mockResolvedValue( + { + subjects: [ + { + subject_type: 'team', + subject_id: 'team-1', + subject_safe_label: 'Knowledge 전담 Team', + }, + ], + next_cursor: null, + }, + ); + knowledgeApiMock.grantKnowledgeCollectionPermissionBundle.mockResolvedValueOnce( + { + permissions: [], + }, + ); render(); @@ -177,7 +232,7 @@ describe('KnowledgeCollectionManager', () => { .find((element) => element.querySelector('option[value="team-1"]')); expect(targetSelect).toBeDefined(); fireEvent.change(targetSelect!, { target: { value: 'team-1' } }); - fireEvent.click(screen.getByRole('button', { name: '부여' })); + fireEvent.click(screen.getByRole('button', { name: 'Bundle 부여' })); await waitFor(() => expect( @@ -188,6 +243,194 @@ describe('KnowledgeCollectionManager', () => { role_bundle: 'viewer', }), ); + + await waitFor(() => + expect( + knowledgeApiMock.getKnowledgeCollectionsResponse, + ).toHaveBeenCalledTimes(2), + ); + const refreshedTargetSelect = screen + .getAllByRole('combobox') + .find((element) => element.querySelector('option[value="team-1"]')); + expect(refreshedTargetSelect).toBeDefined(); + fireEvent.change(refreshedTargetSelect!, { + target: { value: 'team-1' }, + }); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Bundle 부여' })).toBeEnabled(), + ); + fireEvent.click(screen.getByLabelText('HR bulk 권한 대상 선택')); + fireEvent.click( + await screen.findByRole('button', { name: '선택 KC 일괄 부여' }), + ); + await waitFor(() => + expect( + knowledgeApiMock.mutateKnowledgeCollectionPermissionBundles, + ).toHaveBeenCalledWith({ + collection_ids: ['collection-1'], + operation: 'grant', + subject_type: 'team', + subject_id: 'team-1', + role_bundle: 'viewer', + }), + ); + }); + + it('restores an archived manual Collection with delegated lifecycle authority', async () => { + knowledgeApiMock.getKnowledgeDomainCapabilities.mockResolvedValue({ + actions: ['lifecycle_manage'], + can_manage_domain_permissions: false, + can_create_collection: false, + can_delegate_permissions: false, + can_manage_lifecycle: true, + can_manage_sync: false, + can_change_public_visibility: false, + }); + knowledgeApiMock.getKnowledgeCollectionsResponse.mockImplementation( + async (params?: { lifecycle_state?: string }) => ({ + collections: + params?.lifecycle_state === 'archived' + ? [ + { + id: 'collection-archived', + organization_id: 'org-1', + name: 'Archived HR', + description: '복구 대상', + is_system_managed: false, + sync_state: 'manual', + lifecycle_state: 'archived', + visibility: 'private', + linked_kb_count_bucket: '0', + active_kb_count_bucket: '0', + can_read: false, + can_route: false, + can_manage: false, + can_sync: false, + safe_metadata: { safe_label: '보관 인사 문서' }, + created_at: '2026-07-07T00:00:00Z', + updated_at: '2026-07-07T00:00:00Z', + }, + ] + : [], + can_create_collection: false, + can_change_public_visibility: false, + }), + ); + knowledgeApiMock.restoreKnowledgeCollection.mockResolvedValue(undefined); + + render(); + + fireEvent.click(await screen.findByRole('tab', { name: 'archived' })); + const restoreButton = await screen.findByRole('button', { + name: 'Restore', + }); + expect(restoreButton).toBeEnabled(); + fireEvent.click(restoreButton); + + await waitFor(() => + expect(knowledgeApiMock.restoreKnowledgeCollection).toHaveBeenCalledWith( + 'collection-archived', + ), + ); + }); + + it('saves a complete reordered item set with the loaded revision', async () => { + const revision = `ord_v1_${'4'.repeat(64)}`; + knowledgeApiMock.getKnowledgeDomainCapabilities.mockResolvedValue({ + actions: [], + can_manage_domain_permissions: false, + can_create_collection: false, + can_delegate_permissions: false, + can_manage_lifecycle: false, + can_manage_sync: false, + can_change_public_visibility: false, + }); + knowledgeApiMock.getKnowledgeCollectionsResponse.mockResolvedValue({ + collections: [ + { + id: 'collection-order', + organization_id: 'org-1', + name: 'Ordered KC', + description: null, + is_system_managed: false, + sync_state: 'manual', + lifecycle_state: 'active', + visibility: 'private', + linked_kb_count_bucket: '2-10', + active_kb_count_bucket: '2-10', + can_read: true, + can_route: true, + can_manage: true, + can_sync: false, + safe_metadata: { safe_label: '정렬 KC' }, + created_at: '2026-07-07T00:00:00Z', + updated_at: '2026-07-07T00:00:00Z', + }, + ], + can_create_collection: false, + can_change_public_visibility: false, + }); + const first = { + item_id: 'item-1', + knowledge_base_id: 'kb-1', + safe_label: 'First KB', + lifecycle_state: 'active', + sync_state: 'manual', + rank: 0, + can_manage_kb: true, + can_use_kb: true, + }; + const second = { + ...first, + item_id: 'item-2', + safe_label: 'Second KB', + rank: 1, + }; + knowledgeApiMock.getKnowledgeCollectionItems.mockResolvedValue({ + items: [first, second], + order_revision: revision, + reorder_supported: true, + safe_reason_code: null, + }); + knowledgeApiMock.getKnowledgeCollectionLinkCandidates.mockResolvedValue({ + candidates: [], + }); + knowledgeApiMock.getKnowledgeCollectionPermissions.mockResolvedValue({ + permissions: [], + }); + knowledgeApiMock.getKnowledgeCollectionDelegationSubjects.mockResolvedValue( + { + subjects: [], + next_cursor: null, + }, + ); + knowledgeApiMock.reorderKnowledgeCollectionItems.mockResolvedValue({ + items: [second, first], + order_revision: `ord_v1_${'5'.repeat(64)}`, + reorder_supported: true, + safe_reason_code: null, + }); + + render(); + + fireEvent.click( + await screen.findByRole('button', { name: 'First KB 아래로 이동' }), + ); + fireEvent.click(screen.getByRole('button', { name: '순서 저장' })); + + await waitFor(() => + expect( + knowledgeApiMock.reorderKnowledgeCollectionItems, + ).toHaveBeenCalledWith( + 'collection-order', + [ + { item_id: 'item-2', rank: 0 }, + { item_id: 'item-1', rank: 1 }, + ], + revision, + false, + ), + ); }); it('fails closed when delegated capability refresh fails', async () => { @@ -309,6 +552,9 @@ describe('KnowledgeCollectionManager', () => { }); knowledgeApiMock.getKnowledgeCollectionItems.mockResolvedValue({ items: [], + order_revision: `ord_v1_${'3'.repeat(64)}`, + reorder_supported: true, + safe_reason_code: null, }); knowledgeApiMock.getKnowledgeCollectionLinkCandidates.mockResolvedValue({ candidates: [], @@ -318,8 +564,8 @@ describe('KnowledgeCollectionManager', () => { }); knowledgeApiMock.getKnowledgeCollectionDelegationSubjects.mockResolvedValue( { - teams: [], - users: [], + subjects: [], + next_cursor: null, }, ); knowledgeApiMock.updateKnowledgeCollection.mockResolvedValue({ diff --git a/apps/client/app/features/knowledge/components/knowledge-collection-manager.tsx b/apps/client/app/features/knowledge/components/knowledge-collection-manager.tsx index 2a7ec57e9..44bfffb71 100644 --- a/apps/client/app/features/knowledge/components/knowledge-collection-manager.tsx +++ b/apps/client/app/features/knowledge/components/knowledge-collection-manager.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { knowledgeApi, - KnowledgeCollectionItemResponse, + KnowledgeCollectionItemsResponse, KnowledgeCollectionLinkCandidate, KnowledgeCollectionPermissionResponse, KnowledgeCollectionResponse, @@ -50,22 +50,42 @@ export default function KnowledgeCollectionManager() { [], ); const [selectedId, setSelectedId] = useState(null); - const [items, setItems] = useState([]); - const [candidates, setCandidates] = useState( - [], + const [lifecycleState, setLifecycleState] = useState<'active' | 'archived'>( + 'active', ); + const [selectedBulkIds, setSelectedBulkIds] = useState([]); + const [itemData, setItemData] = + useState(null); + const [candidates, setCandidates] = useState< + KnowledgeCollectionLinkCandidate[] + >([]); const [permissions, setPermissions] = useState< KnowledgeCollectionPermissionResponse[] >([]); - const [subjects, setSubjects] = useState({ - teams: [], - users: [], - }); + const [subjects, setSubjects] = useState( + { + subjects: [], + next_cursor: null, + }, + ); const [domainPermissions, setDomainPermissions] = useState< KnowledgeDomainPermissionListResponse['permissions'] >([]); const [domainSubjects, setDomainSubjects] = - useState({ teams: [], users: [] }); + useState({ + subjects: [], + next_cursor: null, + }); + const [collectionSubjectQuery, setCollectionSubjectQuery] = useState(''); + const [domainSubjectQuery, setDomainSubjectQuery] = useState(''); + const [isSubjectLoading, setIsSubjectLoading] = useState(false); + const [isDomainSubjectLoading, setIsDomainSubjectLoading] = useState(false); + const [subjectLoadFailed, setSubjectLoadFailed] = useState(false); + const [domainSubjectLoadFailed, setDomainSubjectLoadFailed] = useState(false); + const [subjectRetryVersion, setSubjectRetryVersion] = useState(0); + const [domainSubjectRetryVersion, setDomainSubjectRetryVersion] = useState(0); + const collectionSubjectRequestVersion = useRef(0); + const domainSubjectRequestVersion = useRef(0); const [capabilities, setCapabilities] = useState({ ...CLOSED_COLLECTION_CAPABILITIES, }); @@ -96,7 +116,8 @@ export default function KnowledgeCollectionManager() { }); const selectedCollection = useMemo( - () => collections.find((collection) => collection.id === selectedId) ?? null, + () => + collections.find((collection) => collection.id === selectedId) ?? null, [collections, selectedId], ); @@ -105,7 +126,9 @@ export default function KnowledgeCollectionManager() { setErrorMessage(null); try { const [data, domainCapabilities] = await Promise.all([ - knowledgeApi.getKnowledgeCollectionsResponse(), + knowledgeApi.getKnowledgeCollectionsResponse({ + lifecycle_state: lifecycleState, + }), knowledgeApi.getKnowledgeDomainCapabilities(), ]); setCollections(data.collections); @@ -114,72 +137,82 @@ export default function KnowledgeCollectionManager() { can_change_public_visibility: domainCapabilities.can_change_public_visibility, can_manage_catalog: domainCapabilities.can_create_collection, - can_delegate_permissions: - domainCapabilities.can_delegate_permissions, + can_delegate_permissions: domainCapabilities.can_delegate_permissions, can_manage_lifecycle: domainCapabilities.can_manage_lifecycle, can_manage_domain_permissions: domainCapabilities.can_manage_domain_permissions, }); if (domainCapabilities.can_manage_domain_permissions) { - const [permissionData, subjectData] = await Promise.all([ - knowledgeApi.getKnowledgeDomainPermissions(), - knowledgeApi.getKnowledgeDomainDelegationSubjects(), - ]); + const permissionData = + await knowledgeApi.getKnowledgeDomainPermissions(); setDomainPermissions(permissionData.permissions); - setDomainSubjects(subjectData); } else { setDomainPermissions([]); - setDomainSubjects({ teams: [], users: [] }); + setDomainSubjects({ subjects: [], next_cursor: null }); } - setSelectedId((currentId) => currentId ?? data.collections[0]?.id ?? null); + setSelectedId((currentId) => + data.collections.some((collection) => collection.id === currentId) + ? currentId + : (data.collections[0]?.id ?? null), + ); + setSelectedBulkIds((currentIds) => + currentIds.filter((id) => + data.collections.some( + (collection) => + collection.id === id && + (collection.can_manage || + domainCapabilities.can_delegate_permissions), + ), + ), + ); } catch (error) { setCapabilities({ ...CLOSED_COLLECTION_CAPABILITIES }); setErrorMessage(errorText(error)); } finally { setIsLoading(false); } - }, []); + }, [lifecycleState]); - const loadCollectionDetail = useCallback(async ( - collection: KnowledgeCollectionResponse, - currentCapabilities: CollectionCapabilities, - ) => { - setIsDetailLoading(true); - setErrorMessage(null); - try { - const canManageCatalog = - collection.can_manage || currentCapabilities.can_manage_catalog; - const canDelegate = - collection.can_manage || currentCapabilities.can_delegate_permissions; - const [itemData, candidateData, permissionData, subjectData] = - await Promise.all([ - collection.can_read || canManageCatalog - ? knowledgeApi.getKnowledgeCollectionItems(collection.id) - : Promise.resolve({ items: [] }), - canManageCatalog - ? knowledgeApi.getKnowledgeCollectionLinkCandidates(collection.id) - : Promise.resolve({ candidates: [] }), - canDelegate - ? knowledgeApi.getKnowledgeCollectionPermissions(collection.id) - : Promise.resolve({ permissions: [] }), - canDelegate - ? knowledgeApi.getKnowledgeCollectionDelegationSubjects(collection.id) - : Promise.resolve({ teams: [], users: [] }), - ]); - setItems(itemData.items); - setCandidates(candidateData.candidates); - setPermissions(permissionData.permissions); - setSubjects(subjectData); - } catch (error) { - setItems([]); - setCandidates([]); - setPermissions([]); - setSubjects({ teams: [], users: [] }); - setErrorMessage(errorText(error)); - } finally { - setIsDetailLoading(false); - } - }, []); + const loadCollectionDetail = useCallback( + async ( + collection: KnowledgeCollectionResponse, + currentCapabilities: CollectionCapabilities, + ) => { + setIsDetailLoading(true); + setErrorMessage(null); + try { + const canManageCatalog = + collection.can_manage || currentCapabilities.can_manage_catalog; + const canDelegate = + collection.can_manage || currentCapabilities.can_delegate_permissions; + const [nextItemData, candidateData, permissionData] = await Promise.all( + [ + collection.can_read || canManageCatalog + ? knowledgeApi.getKnowledgeCollectionItems(collection.id) + : Promise.resolve(null), + collection.lifecycle_state === 'active' && canManageCatalog + ? knowledgeApi.getKnowledgeCollectionLinkCandidates(collection.id) + : Promise.resolve({ candidates: [] }), + canDelegate + ? knowledgeApi.getKnowledgeCollectionPermissions(collection.id) + : Promise.resolve({ permissions: [] }), + ], + ); + setItemData(nextItemData); + setCandidates(candidateData.candidates); + setPermissions(permissionData.permissions); + } catch (error) { + setItemData(null); + setCandidates([]); + setPermissions([]); + setSubjects({ subjects: [], next_cursor: null }); + setErrorMessage(errorText(error)); + } finally { + setIsDetailLoading(false); + } + }, + [], + ); useEffect(() => { loadCollections(); @@ -195,10 +228,142 @@ export default function KnowledgeCollectionManager() { ? selectedCollection.safe_metadata.safe_label : '', }); - setAcknowledgePublic(false); loadCollectionDetail(selectedCollection, capabilities); }, [capabilities, loadCollectionDetail, selectedCollection]); + useEffect(() => { + setAcknowledgePublic(false); + setCollectionSubjectQuery(''); + setSubjects({ subjects: [], next_cursor: null }); + setGrantForm((current) => ({ ...current, subject_id: '' })); + }, [selectedId]); + + useEffect(() => { + if ( + !selectedCollection || + !(selectedCollection.can_manage || capabilities.can_delegate_permissions) + ) { + collectionSubjectRequestVersion.current += 1; + setSubjects({ subjects: [], next_cursor: null }); + setIsSubjectLoading(false); + setSubjectLoadFailed(false); + return; + } + let cancelled = false; + const requestVersion = ++collectionSubjectRequestVersion.current; + const timer = window.setTimeout(async () => { + setIsSubjectLoading(true); + setSubjectLoadFailed(false); + setErrorMessage(null); + try { + const response = + await knowledgeApi.getKnowledgeCollectionDelegationSubjects( + selectedCollection.id, + { + subject_type: grantForm.subject_type, + query: collectionSubjectQuery.trim() || undefined, + limit: 25, + }, + ); + if ( + !cancelled && + collectionSubjectRequestVersion.current === requestVersion + ) { + setSubjects(response); + setSubjectLoadFailed(false); + } + } catch (error) { + if ( + !cancelled && + collectionSubjectRequestVersion.current === requestVersion + ) { + setSubjects({ subjects: [], next_cursor: null }); + setSubjectLoadFailed(true); + setErrorMessage(errorText(error)); + } + } finally { + if ( + !cancelled && + collectionSubjectRequestVersion.current === requestVersion + ) { + setIsSubjectLoading(false); + } + } + }, 250); + return () => { + cancelled = true; + window.clearTimeout(timer); + if (collectionSubjectRequestVersion.current === requestVersion) { + collectionSubjectRequestVersion.current += 1; + } + }; + }, [ + capabilities.can_delegate_permissions, + collectionSubjectQuery, + grantForm.subject_type, + selectedCollection, + subjectRetryVersion, + ]); + + useEffect(() => { + if (!capabilities.can_manage_domain_permissions) { + domainSubjectRequestVersion.current += 1; + setIsDomainSubjectLoading(false); + setDomainSubjectLoadFailed(false); + return; + } + let cancelled = false; + const requestVersion = ++domainSubjectRequestVersion.current; + const timer = window.setTimeout(async () => { + setIsDomainSubjectLoading(true); + setDomainSubjectLoadFailed(false); + setErrorMessage(null); + try { + const response = + await knowledgeApi.getKnowledgeDomainDelegationSubjects({ + subject_type: domainGrantForm.subject_type, + query: domainSubjectQuery.trim() || undefined, + limit: 25, + }); + if ( + !cancelled && + domainSubjectRequestVersion.current === requestVersion + ) { + setDomainSubjects(response); + setDomainSubjectLoadFailed(false); + } + } catch (error) { + if ( + !cancelled && + domainSubjectRequestVersion.current === requestVersion + ) { + setDomainSubjects({ subjects: [], next_cursor: null }); + setDomainSubjectLoadFailed(true); + setErrorMessage(errorText(error)); + } + } finally { + if ( + !cancelled && + domainSubjectRequestVersion.current === requestVersion + ) { + setIsDomainSubjectLoading(false); + } + } + }, 250); + return () => { + cancelled = true; + window.clearTimeout(timer); + if (domainSubjectRequestVersion.current === requestVersion) { + domainSubjectRequestVersion.current += 1; + } + }; + }, [ + capabilities.can_manage_domain_permissions, + domainGrantForm.subject_type, + domainSubjectQuery, + domainSubjectRetryVersion, + ]); + const refreshSelected = async () => { await loadCollections(); if (selectedCollection) { @@ -206,6 +371,85 @@ export default function KnowledgeCollectionManager() { } }; + const loadMoreCollectionSubjects = async () => { + if (!selectedCollection || !subjects.next_cursor || isSubjectLoading) + return; + const requestVersion = ++collectionSubjectRequestVersion.current; + setIsSubjectLoading(true); + try { + const response = + await knowledgeApi.getKnowledgeCollectionDelegationSubjects( + selectedCollection.id, + { + subject_type: grantForm.subject_type, + query: collectionSubjectQuery.trim() || undefined, + cursor: subjects.next_cursor, + limit: 25, + }, + ); + if (collectionSubjectRequestVersion.current === requestVersion) { + setSubjects((current) => ({ + subjects: [ + ...current.subjects, + ...response.subjects.filter( + (candidate) => + !current.subjects.some( + (subject) => subject.subject_id === candidate.subject_id, + ), + ), + ], + next_cursor: response.next_cursor, + })); + } + } catch (error) { + if (collectionSubjectRequestVersion.current === requestVersion) { + setSubjectLoadFailed(true); + setErrorMessage(errorText(error)); + } + } finally { + if (collectionSubjectRequestVersion.current === requestVersion) { + setIsSubjectLoading(false); + } + } + }; + + const loadMoreDomainSubjects = async () => { + if (!domainSubjects.next_cursor || isDomainSubjectLoading) return; + const requestVersion = ++domainSubjectRequestVersion.current; + setIsDomainSubjectLoading(true); + try { + const response = await knowledgeApi.getKnowledgeDomainDelegationSubjects({ + subject_type: domainGrantForm.subject_type, + query: domainSubjectQuery.trim() || undefined, + cursor: domainSubjects.next_cursor, + limit: 25, + }); + if (domainSubjectRequestVersion.current === requestVersion) { + setDomainSubjects((current) => ({ + subjects: [ + ...current.subjects, + ...response.subjects.filter( + (candidate) => + !current.subjects.some( + (subject) => subject.subject_id === candidate.subject_id, + ), + ), + ], + next_cursor: response.next_cursor, + })); + } + } catch (error) { + if (domainSubjectRequestVersion.current === requestVersion) { + setDomainSubjectLoadFailed(true); + setErrorMessage(errorText(error)); + } + } finally { + if (domainSubjectRequestVersion.current === requestVersion) { + setIsDomainSubjectLoading(false); + } + } + }; + const createCollection = async () => { if (!form.name.trim() || !form.safeLabel.trim()) return; setIsSaving(true); @@ -270,20 +514,40 @@ export default function KnowledgeCollectionManager() { } }; + const restoreCollection = async () => { + if (!selectedCollection) return; + setIsSaving(true); + setErrorMessage(null); + try { + await knowledgeApi.restoreKnowledgeCollection(selectedCollection.id); + setSelectedId(null); + await loadCollections(); + } catch (error) { + setErrorMessage(errorText(error)); + } finally { + setIsSaving(false); + } + }; + const linkCandidate = async (candidateId: string) => { if (!selectedCollection) return; setIsSaving(true); setErrorMessage(null); try { - await knowledgeApi.linkKnowledgeCollectionItem(selectedCollection.id, { - knowledge_base_id: candidateId, - acknowledged_public_runtime_exposure: - selectedCollection.visibility === 'public' && acknowledgePublic, - }); - await loadCollectionDetail( - selectedCollection, - capabilities, + const response = await knowledgeApi.linkKnowledgeCollectionItem( + selectedCollection.id, + { + knowledge_base_id: candidateId, + acknowledged_public_runtime_exposure: + selectedCollection.visibility === 'public' && acknowledgePublic, + }, ); + setItemData(response); + const candidateData = + await knowledgeApi.getKnowledgeCollectionLinkCandidates( + selectedCollection.id, + ); + setCandidates(candidateData.candidates); await loadCollections(); } catch (error) { setErrorMessage(errorText(error)); @@ -292,6 +556,29 @@ export default function KnowledgeCollectionManager() { } }; + const reorderItems = async ( + orderedItems: { item_id: string; rank: number }[], + expectedOrderRevision: string, + ) => { + if (!selectedCollection) return; + setIsSaving(true); + setErrorMessage(null); + try { + const response = await knowledgeApi.reorderKnowledgeCollectionItems( + selectedCollection.id, + orderedItems, + expectedOrderRevision, + selectedCollection.visibility === 'public' && acknowledgePublic, + ); + setItemData(response); + } catch (error) { + setErrorMessage(errorText(error)); + throw error; + } finally { + setIsSaving(false); + } + }; + const unlinkItem = async (itemId: string) => { if (!selectedCollection) return; setIsSaving(true); @@ -302,10 +589,7 @@ export default function KnowledgeCollectionManager() { itemId, selectedCollection.visibility === 'public' && acknowledgePublic, ); - await loadCollectionDetail( - selectedCollection, - capabilities, - ); + await loadCollectionDetail(selectedCollection, capabilities); await loadCollections(); } catch (error) { setErrorMessage(errorText(error)); @@ -331,10 +615,47 @@ export default function KnowledgeCollectionManager() { subject_id: '', role_bundle: 'viewer', }); - await loadCollectionDetail( - selectedCollection, - capabilities, + await loadCollections(); + } catch (error) { + setErrorMessage(errorText(error)); + } finally { + setIsSaving(false); + } + }; + + const revokePermissionBundle = async () => { + if (!selectedCollection || !grantForm.subject_id.trim()) return; + setIsSaving(true); + setErrorMessage(null); + try { + await knowledgeApi.revokeKnowledgeCollectionPermissionBundle( + selectedCollection.id, + { + ...grantForm, + subject_id: grantForm.subject_id.trim(), + }, ); + await loadCollections(); + } catch (error) { + setErrorMessage(errorText(error)); + } finally { + setIsSaving(false); + } + }; + + const mutatePermissionBundleBulk = async (operation: 'grant' | 'revoke') => { + if (selectedBulkIds.length === 0 || !grantForm.subject_id.trim()) return; + setIsSaving(true); + setErrorMessage(null); + try { + await knowledgeApi.mutateKnowledgeCollectionPermissionBundles({ + collection_ids: selectedBulkIds, + operation, + subject_type: grantForm.subject_type, + subject_id: grantForm.subject_id.trim(), + role_bundle: grantForm.role_bundle, + }); + await loadCollections(); } catch (error) { setErrorMessage(errorText(error)); } finally { @@ -351,10 +672,7 @@ export default function KnowledgeCollectionManager() { selectedCollection.id, permissionId, ); - await loadCollectionDetail( - selectedCollection, - capabilities, - ); + await loadCollections(); } catch (error) { setErrorMessage(errorText(error)); } finally { @@ -362,16 +680,21 @@ export default function KnowledgeCollectionManager() { } }; - const updateVisibility = async (visibility: KnowledgeCollectionVisibility) => { + const updateVisibility = async ( + visibility: KnowledgeCollectionVisibility, + ) => { if (!selectedCollection) return; setIsSaving(true); setErrorMessage(null); try { - await knowledgeApi.updateKnowledgeCollectionVisibility(selectedCollection.id, { - visibility, - acknowledged_public_runtime_exposure: - visibility === 'public' ? acknowledgePublic : true, - }); + await knowledgeApi.updateKnowledgeCollectionVisibility( + selectedCollection.id, + { + visibility, + acknowledged_public_runtime_exposure: + visibility === 'public' ? acknowledgePublic : true, + }, + ); setAcknowledgePublic(false); await refreshSelected(); } catch (error) { @@ -381,6 +704,25 @@ export default function KnowledgeCollectionManager() { } }; + const toggleBulkCollection = (collectionId: string) => { + setSelectedBulkIds((current) => { + if (current.includes(collectionId)) { + return current.filter((id) => id !== collectionId); + } + return current.length >= 50 ? current : [...current, collectionId]; + }); + }; + + const updateCollectionSubjectQuery = (query: string) => { + setCollectionSubjectQuery(query); + setGrantForm((current) => ({ ...current, subject_id: '' })); + }; + + const updateDomainSubjectQuery = (query: string) => { + setDomainSubjectQuery(query); + setDomainGrantForm((current) => ({ ...current, subject_id: '' })); + }; + const grantDomainPermission = async () => { if (!domainGrantForm.subject_id) return; setIsSaving(true); @@ -432,11 +774,19 @@ export default function KnowledgeCollectionManager() { {capabilities.can_manage_domain_permissions && ( + setDomainSubjectRetryVersion((current) => current + 1) + } onRevoke={revokeDomainPermission} + onSubjectQueryChange={updateDomainSubjectQuery} setForm={setDomainGrantForm} /> )} @@ -448,9 +798,13 @@ export default function KnowledgeCollectionManager() { form={form} isLoading={isLoading} isSaving={isSaving} + lifecycleState={lifecycleState} + selectedBulkIds={selectedBulkIds} selectedId={selectedId} onCreate={createCollection} onSelect={setSelectedId} + onLifecycleStateChange={setLifecycleState} + onToggleBulkCollection={toggleBulkCollection} setForm={setForm} /> @@ -463,14 +817,27 @@ export default function KnowledgeCollectionManager() { editForm={editForm} grantForm={grantForm} isDetailLoading={isDetailLoading} + hasSubjectLoadError={subjectLoadFailed} + isSubjectLoading={isSubjectLoading} isSaving={isSaving} - items={items} + itemData={itemData} permissions={permissions} + selectedBulkCount={selectedBulkIds.length} + subjectQuery={collectionSubjectQuery} subjects={subjects} onArchive={archiveCollection} + onBulkPermission={mutatePermissionBundleBulk} onGrantPermission={grantPermission} onLinkCandidate={linkCandidate} + onLoadMoreSubjects={loadMoreCollectionSubjects} + onRetrySubjects={() => + setSubjectRetryVersion((current) => current + 1) + } + onReorderItems={reorderItems} onRevokePermission={revokePermission} + onRevokePermissionBundle={revokePermissionBundle} + onRestore={restoreCollection} + onSubjectQueryChange={updateCollectionSubjectQuery} onUnlinkItem={unlinkItem} onUpdateCollection={updateCollection} onUpdateVisibility={updateVisibility} diff --git a/apps/client/app/features/knowledge/types/Knowledge.ts b/apps/client/app/features/knowledge/types/Knowledge.ts index bae11096b..508fb4293 100644 --- a/apps/client/app/features/knowledge/types/Knowledge.ts +++ b/apps/client/app/features/knowledge/types/Knowledge.ts @@ -213,6 +213,9 @@ export interface KnowledgeCollectionItemResponse { export interface KnowledgeCollectionItemsResponse { items: KnowledgeCollectionItemResponse[]; + order_revision: string; + reorder_supported: boolean; + safe_reason_code?: 'item_reorder_limit_exceeded' | null; } export interface KnowledgeCollectionLinkCandidate { @@ -245,8 +248,17 @@ export interface KnowledgeDelegationSubject { } export interface KnowledgeDelegationSubjectsResponse { - teams: KnowledgeDelegationSubject[]; - users: KnowledgeDelegationSubject[]; + subjects: KnowledgeDelegationSubject[]; + next_cursor?: string | null; +} + +export interface KnowledgeCollectionPermissionBulkBundleResponse { + operation: 'grant' | 'revoke'; + subject_type: 'team' | 'user'; + role_bundle: KnowledgeCollectionRoleBundle; + target_count_bucket: '0' | '1' | '2-10' | '11-50'; + changed_count_bucket: '0' | '1' | '2-10' | '11-50'; + unchanged_count_bucket: '0' | '1' | '2-10' | '11-50'; } export interface KnowledgeDomainCapabilitiesResponse { diff --git a/apps/gateway/adapters/audit/knowledge_collection_operations.py b/apps/gateway/adapters/audit/knowledge_collection_operations.py new file mode 100644 index 000000000..4bb64d6d2 --- /dev/null +++ b/apps/gateway/adapters/audit/knowledge_collection_operations.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from apps.gateway.application.knowledge_administration.collection_operations import ( + CollectionOperationCommand, +) +from apps.gateway.services.audit_records import add_data_change_audit + + +class SqlAlchemyKnowledgeCollectionOperationAudit: + def __init__(self, db: Session) -> None: + self.db = db + + def record( + self, + *, + command: CollectionOperationCommand, + action: str, + before: dict[str, object] | None = None, + after: dict[str, object] | None = None, + metadata: dict[str, object] | None = None, + ) -> None: + add_data_change_audit( + self.db, + action, + command.actor_id, + "knowledge_collection", + command.collection_id, + before=before, + after=after, + organization_id=command.organization_id, + metadata=metadata, + ) diff --git a/apps/gateway/adapters/db/knowledge_collection_operations.py b/apps/gateway/adapters/db/knowledge_collection_operations.py new file mode 100644 index 000000000..d7e2ee363 --- /dev/null +++ b/apps/gateway/adapters/db/knowledge_collection_operations.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Sequence + +from sqlalchemy.orm import Session + +from apps.gateway.application.knowledge_administration.collection_operations import ( + CollectionItemOrderSnapshot, + CollectionItemRank, + CollectionSnapshot, +) +from apps.shared.db.models.knowledge import ( + KnowledgeBase, + KnowledgeCollection, + KnowledgeCollectionItem, +) +from apps.shared.services.knowledge_permission_service import KnowledgePermissionHelper +from apps.shared.services.permissions import ( + get_effective_knowledge_domain_actions, + has_organization_manager_permission, +) + + +class SqlAlchemyKnowledgeCollectionOperationAuthorization: + def __init__(self, db: Session) -> None: + self.db = db + + def is_organization_manager( + self, actor_id: uuid.UUID, organization_id: uuid.UUID + ) -> bool: + return has_organization_manager_permission( + self.db, actor_id, organization_id + ) + + def has_domain_action( + self, + actor_id: uuid.UUID, + organization_id: uuid.UUID, + action: str, + ) -> bool: + return action in get_effective_knowledge_domain_actions( + self.db, actor_id, organization_id + ) + + def has_collection_action( + self, + actor_id: uuid.UUID, + organization_id: uuid.UUID, + collection_id: uuid.UUID, + action: str, + ) -> bool: + collection = ( + self.db.query(KnowledgeCollection) + .filter( + KnowledgeCollection.id == collection_id, + KnowledgeCollection.organization_id == organization_id, + ) + .first() + ) + if collection is None: + return False + helper = KnowledgePermissionHelper( + self.db, + user_id=actor_id, + organization_id=organization_id, + ) + return helper.evaluate_collection_action( + collection, + action, + include_archived=True, + ).allowed + + +class SqlAlchemyKnowledgeCollectionOperationRepository: + def __init__(self, db: Session) -> None: + self.db = db + self._locked_collections: dict[uuid.UUID, KnowledgeCollection] = {} + self._locked_items: dict[uuid.UUID, KnowledgeCollectionItem] = {} + + def lock_collection( + self, organization_id: uuid.UUID, collection_id: uuid.UUID + ) -> CollectionSnapshot | None: + collection = ( + self.db.query(KnowledgeCollection) + .filter( + KnowledgeCollection.id == collection_id, + KnowledgeCollection.organization_id == organization_id, + ) + .with_for_update() + .first() + ) + if collection is None: + return None + self._locked_collections[collection.id] = collection + metadata = dict(collection.safe_metadata or {}) + return CollectionSnapshot( + collection_id=collection.id, + lifecycle_state=collection.lifecycle_state, + sync_state=collection.sync_state, + is_system_managed=collection.is_system_managed, + is_source_managed=( + collection.source_identity_id is not None + or bool(collection.source_connector_ref) + ), + visibility=( + "public" if metadata.get("visibility") == "public" else "private" + ), + ) + + def set_lifecycle_state( + self, + organization_id: uuid.UUID, + collection_id: uuid.UUID, + lifecycle_state: str, + ) -> None: + collection = self._locked_collection(organization_id, collection_id) + collection.lifecycle_state = lifecycle_state + collection.updated_at = datetime.now(timezone.utc) + + def lock_item_order( + self, organization_id: uuid.UUID, collection_id: uuid.UUID + ) -> list[CollectionItemOrderSnapshot]: + rows = ( + self.db.query(KnowledgeCollectionItem) + .filter( + KnowledgeCollectionItem.organization_id == organization_id, + KnowledgeCollectionItem.collection_id == collection_id, + ) + .order_by( + KnowledgeCollectionItem.rank.asc(), + KnowledgeCollectionItem.created_at.asc(), + KnowledgeCollectionItem.id.asc(), + ) + .with_for_update() + .all() + ) + self._locked_items = {row.id: row for row in rows} + return [ + CollectionItemOrderSnapshot( + item_id=row.id, + rank=row.rank, + created_at=row.created_at, + ) + for row in rows + ] + + def set_item_ranks( + self, + organization_id: uuid.UUID, + collection_id: uuid.UUID, + ranks: Sequence[CollectionItemRank], + ) -> None: + expected_ids = {row.item_id for row in ranks} + if expected_ids != set(self._locked_items): + raise RuntimeError("collection item lock set changed") + for rank in ranks: + self._locked_items[rank.item_id].rank = rank.rank + collection = self._locked_collection(organization_id, collection_id) + collection.updated_at = datetime.now(timezone.utc) + + def has_source_managed_items( + self, + organization_id: uuid.UUID, + collection_id: uuid.UUID, + ) -> bool: + return ( + self.db.query(KnowledgeCollectionItem.id) + .join( + KnowledgeBase, + KnowledgeBase.id == KnowledgeCollectionItem.knowledge_base_id, + ) + .filter( + KnowledgeCollectionItem.organization_id == organization_id, + KnowledgeCollectionItem.collection_id == collection_id, + KnowledgeBase.organization_id == organization_id, + KnowledgeBase.source_identity_id.is_not(None), + ) + .first() + is not None + ) + + def _locked_collection( + self, organization_id: uuid.UUID, collection_id: uuid.UUID + ) -> KnowledgeCollection: + collection = self._locked_collections.get(collection_id) + if collection is None or collection.organization_id != organization_id: + raise RuntimeError("collection row must be locked before mutation") + return collection diff --git a/apps/gateway/api/v1/endpoints/knowledge.py b/apps/gateway/api/v1/endpoints/knowledge.py index 88762810f..5f2145233 100644 --- a/apps/gateway/api/v1/endpoints/knowledge.py +++ b/apps/gateway/api/v1/endpoints/knowledge.py @@ -26,8 +26,21 @@ DomainPermissionSubjectHidden, OrganizationManagerRequired, ) +from apps.gateway.application.knowledge_administration.collection_operations import ( + CollectionHidden, + CollectionInputInvalid, + CollectionItemRank, + CollectionOperationCommand, + CollectionPermissionDenied, + CollectionPersistenceFailed, + CollectionPolicyBlocked, + CollectionPolicyDenied, + CollectionStateConflict, + ReorderCollectionItemsCommand, +) from apps.gateway.auth.dependencies import get_current_user from apps.gateway.composition.knowledge_administration import ( + build_knowledge_collection_lifecycle_and_order_use_case, build_knowledge_domain_permission_use_case, ) from apps.gateway.utils.api_errors import raise_api_error @@ -98,6 +111,8 @@ KnowledgeCollectionListResponse, KnowledgeCollectionPermissionGrantRequest, KnowledgeCollectionPermissionBundleGrantRequest, + KnowledgeCollectionPermissionBulkBundleRequest, + KnowledgeCollectionPermissionBulkBundleResponse, KnowledgeCollectionPermissionsResponse, KnowledgeCollectionResponse, KnowledgeCollectionUpdateRequest, @@ -302,6 +317,61 @@ def _raise_collection_service_error( ) +def _raise_collection_operation_error(request: Request, exc: Exception) -> None: + if isinstance(exc, CollectionHidden): + raise_api_error( + request, + status.HTTP_404_NOT_FOUND, + "resource.hidden", + "Resource not found.", + ) + if isinstance(exc, CollectionPermissionDenied): + raise_api_error( + request, + status.HTTP_403_FORBIDDEN, + "permission.denied", + "Knowledge Collection permission is required.", + ) + if isinstance(exc, CollectionPolicyDenied): + raise_api_error( + request, + status.HTTP_403_FORBIDDEN, + "policy.denied", + "System-managed collections cannot be manually changed.", + ) + if isinstance(exc, CollectionPolicyBlocked): + raise_api_error( + request, + status.HTTP_409_CONFLICT, + "policy.blocked", + "Knowledge Collection change is blocked by policy.", + {"policy_reason": exc.reason_code}, + ) + if isinstance(exc, CollectionInputInvalid): + raise_api_error( + request, + status.HTTP_400_BAD_REQUEST, + "validation.failed", + "Knowledge Collection request is invalid.", + ) + if isinstance(exc, CollectionStateConflict): + raise_api_error( + request, + status.HTTP_409_CONFLICT, + "conflict", + "Knowledge Collection state changed. Reload and try again.", + {"reason": exc.reason_code}, + ) + if isinstance(exc, CollectionPersistenceFailed): + raise_api_error( + request, + status.HTTP_500_INTERNAL_SERVER_ERROR, + "knowledge.collection_write_failed", + "Knowledge Collection change could not be saved.", + ) + raise exc + + def _raise_domain_permission_error(request: Request, exc: Exception) -> None: if isinstance(exc, OrganizationManagerRequired): raise_api_error( @@ -758,13 +828,22 @@ def list_knowledge_domain_permissions( ) def list_knowledge_domain_delegation_subjects( request: Request, + subject_type: str | None = Query(default=None), + query: str | None = Query(default=None), + cursor: str | None = Query(default=None), + limit: str = Query(default="25"), x_organization_id: str | None = Header(default=None, alias="X-Organization-Id"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): service = _knowledge_collection_service(db, request, x_organization_id, current_user) try: - return service.list_domain_delegation_subjects() + return service.list_domain_delegation_subjects( + subject_type=subject_type, + query=query, + cursor=cursor, + limit=limit, + ) except KnowledgeCollectionServiceError as exc: _raise_collection_service_error(request, exc) @@ -944,6 +1023,24 @@ def list_knowledge_collections( return KnowledgeCollectionListResponse(collections=collections, **capabilities) +@router.post( + "/collection-permissions/bulk-bundles", + response_model=KnowledgeCollectionPermissionBulkBundleResponse, +) +def mutate_knowledge_collection_permission_bundles( + permission_request: KnowledgeCollectionPermissionBulkBundleRequest, + request: Request, + x_organization_id: str | None = Header(default=None, alias="X-Organization-Id"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + service = _knowledge_collection_service(db, request, x_organization_id, current_user) + try: + return service.mutate_permission_bundle_bulk(permission_request) + except KnowledgeCollectionServiceError as exc: + _raise_collection_service_error(request, exc) + + @router.post( "/collections", response_model=KnowledgeCollectionResponse, @@ -1002,11 +1099,60 @@ def archive_knowledge_collection( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - service = _knowledge_collection_service(db, request, x_organization_id, current_user) + organization_id = resolve_active_organization_id( + db, request, x_organization_id, current_user.id + ) try: - service.archive_collection(collection_id) - except KnowledgeCollectionServiceError as exc: - _raise_collection_service_error(request, exc) + build_knowledge_collection_lifecycle_and_order_use_case(db).archive( + CollectionOperationCommand( + actor_id=current_user.id, + organization_id=organization_id, + collection_id=collection_id, + ) + ) + except ( + CollectionHidden, + CollectionPermissionDenied, + CollectionPolicyBlocked, + CollectionPolicyDenied, + CollectionStateConflict, + CollectionPersistenceFailed, + ) as exc: + _raise_collection_operation_error(request, exc) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post( + "/collections/{collection_id}/restore", + status_code=status.HTTP_204_NO_CONTENT, +) +def restore_knowledge_collection( + collection_id: UUID, + request: Request, + x_organization_id: str | None = Header(default=None, alias="X-Organization-Id"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + organization_id = resolve_active_organization_id( + db, request, x_organization_id, current_user.id + ) + try: + build_knowledge_collection_lifecycle_and_order_use_case(db).restore( + CollectionOperationCommand( + actor_id=current_user.id, + organization_id=organization_id, + collection_id=collection_id, + ) + ) + except ( + CollectionHidden, + CollectionPermissionDenied, + CollectionPolicyBlocked, + CollectionPolicyDenied, + CollectionStateConflict, + CollectionPersistenceFailed, + ) as exc: + _raise_collection_operation_error(request, exc) return Response(status_code=status.HTTP_204_NO_CONTENT) @@ -1023,7 +1169,7 @@ def list_knowledge_collection_items( ): service = _knowledge_collection_service(db, request, x_organization_id, current_user) try: - return KnowledgeCollectionItemsResponse(items=service.list_items(collection_id)) + return service.list_items_response(collection_id) except KnowledgeCollectionServiceError as exc: _raise_collection_service_error(request, exc) @@ -1042,8 +1188,7 @@ def link_knowledge_collection_item( ): service = _knowledge_collection_service(db, request, x_organization_id, current_user) try: - item = service.link_item(collection_id, item_request) - return KnowledgeCollectionItemsResponse(items=[item]) + return service.link_item(collection_id, item_request) except KnowledgeCollectionServiceError as exc: _raise_collection_service_error(request, exc) @@ -1060,11 +1205,40 @@ def reorder_knowledge_collection_items( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - service = _knowledge_collection_service(db, request, x_organization_id, current_user) + organization_id = resolve_active_organization_id( + db, request, x_organization_id, current_user.id + ) try: - return KnowledgeCollectionItemsResponse( - items=service.reorder_items(collection_id, reorder_request) + build_knowledge_collection_lifecycle_and_order_use_case(db).reorder( + ReorderCollectionItemsCommand( + actor_id=current_user.id, + organization_id=organization_id, + collection_id=collection_id, + expected_order_revision=reorder_request.expected_order_revision, + items=tuple( + CollectionItemRank(item_id=item.item_id, rank=item.rank) + for item in reorder_request.items + ), + acknowledged_public_runtime_exposure=( + reorder_request.acknowledged_public_runtime_exposure + ), + ) ) + return KnowledgeCollectionService( + db, + user_id=current_user.id, + organization_id=organization_id, + ).list_items_management_response(collection_id) + except ( + CollectionHidden, + CollectionPermissionDenied, + CollectionPolicyBlocked, + CollectionPolicyDenied, + CollectionStateConflict, + CollectionInputInvalid, + CollectionPersistenceFailed, + ) as exc: + _raise_collection_operation_error(request, exc) except KnowledgeCollectionServiceError as exc: _raise_collection_service_error(request, exc) @@ -1144,13 +1318,23 @@ def list_knowledge_collection_permissions( def list_knowledge_collection_delegation_subjects( collection_id: UUID, request: Request, + subject_type: str | None = Query(default=None), + query: str | None = Query(default=None), + cursor: str | None = Query(default=None), + limit: str = Query(default="25"), x_organization_id: str | None = Header(default=None, alias="X-Organization-Id"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): service = _knowledge_collection_service(db, request, x_organization_id, current_user) try: - return service.list_delegation_subjects(collection_id) + return service.list_delegation_subjects( + collection_id, + subject_type=subject_type, + query=query, + cursor=cursor, + limit=limit, + ) except KnowledgeCollectionServiceError as exc: _raise_collection_service_error(request, exc) @@ -1199,6 +1383,26 @@ def grant_knowledge_collection_permission_bundle( _raise_collection_service_error(request, exc) +@router.post( + "/collections/{collection_id}/permissions/bundles/revoke", + status_code=status.HTTP_204_NO_CONTENT, +) +def revoke_knowledge_collection_permission_bundle( + collection_id: UUID, + permission_request: KnowledgeCollectionPermissionBundleGrantRequest, + request: Request, + x_organization_id: str | None = Header(default=None, alias="X-Organization-Id"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + service = _knowledge_collection_service(db, request, x_organization_id, current_user) + try: + service.revoke_permission_bundle(collection_id, permission_request) + except KnowledgeCollectionServiceError as exc: + _raise_collection_service_error(request, exc) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + @router.delete( "/collections/{collection_id}/permissions/{permission_id}", status_code=status.HTTP_204_NO_CONTENT, diff --git a/apps/gateway/application/knowledge_administration/collection_operations.py b/apps/gateway/application/knowledge_administration/collection_operations.py new file mode 100644 index 000000000..ebfc05e4e --- /dev/null +++ b/apps/gateway/application/knowledge_administration/collection_operations.py @@ -0,0 +1,431 @@ +from __future__ import annotations + +import hashlib +import uuid +from dataclasses import dataclass +from datetime import datetime +from typing import Literal, Protocol, Sequence + + +MAX_REORDER_ITEMS = 500 +ORDER_REVISION_PREFIX = "ord_v1_" + + +@dataclass(frozen=True) +class CollectionOperationCommand: + actor_id: uuid.UUID + organization_id: uuid.UUID + collection_id: uuid.UUID + + +@dataclass(frozen=True) +class CollectionSnapshot: + collection_id: uuid.UUID + lifecycle_state: str + sync_state: str + is_system_managed: bool + is_source_managed: bool + visibility: str + + +@dataclass(frozen=True) +class CollectionItemOrderSnapshot: + item_id: uuid.UUID + rank: int + created_at: datetime + + +@dataclass(frozen=True) +class CollectionItemRank: + item_id: uuid.UUID + rank: int + + +@dataclass(frozen=True) +class ReorderCollectionItemsCommand(CollectionOperationCommand): + expected_order_revision: str + items: tuple[CollectionItemRank, ...] + acknowledged_public_runtime_exposure: bool = False + + +@dataclass(frozen=True) +class CollectionMutationResult: + status: Literal["changed", "unchanged"] + + +@dataclass(frozen=True) +class CollectionOrderMutationResult: + status: Literal["changed", "unchanged"] + order_revision: str + + +class CollectionHidden(Exception): + pass + + +class CollectionPermissionDenied(Exception): + pass + + +class CollectionPolicyDenied(Exception): + pass + + +class CollectionPolicyBlocked(Exception): + def __init__(self, reason_code: str) -> None: + super().__init__(reason_code) + self.reason_code = reason_code + + +class CollectionStateConflict(Exception): + def __init__(self, reason_code: str) -> None: + super().__init__(reason_code) + self.reason_code = reason_code + + +class CollectionInputInvalid(Exception): + pass + + +class CollectionPersistenceFailed(Exception): + pass + + +class CollectionOperationAuthorizationPort(Protocol): + def is_organization_manager( + self, actor_id: uuid.UUID, organization_id: uuid.UUID + ) -> bool: ... + + def has_domain_action( + self, + actor_id: uuid.UUID, + organization_id: uuid.UUID, + action: str, + ) -> bool: ... + + def has_collection_action( + self, + actor_id: uuid.UUID, + organization_id: uuid.UUID, + collection_id: uuid.UUID, + action: str, + ) -> bool: ... + + +class CollectionOperationRepositoryPort(Protocol): + def lock_collection( + self, organization_id: uuid.UUID, collection_id: uuid.UUID + ) -> CollectionSnapshot | None: ... + + def set_lifecycle_state( + self, + organization_id: uuid.UUID, + collection_id: uuid.UUID, + lifecycle_state: str, + ) -> None: ... + + def lock_item_order( + self, organization_id: uuid.UUID, collection_id: uuid.UUID + ) -> list[CollectionItemOrderSnapshot]: ... + + def set_item_ranks( + self, + organization_id: uuid.UUID, + collection_id: uuid.UUID, + ranks: Sequence[CollectionItemRank], + ) -> None: ... + + def has_source_managed_items( + self, + organization_id: uuid.UUID, + collection_id: uuid.UUID, + ) -> bool: ... + + +class CollectionOperationAuditPort(Protocol): + def record( + self, + *, + command: CollectionOperationCommand, + action: str, + before: dict[str, object] | None = None, + after: dict[str, object] | None = None, + metadata: dict[str, object] | None = None, + ) -> None: ... + + +class UnitOfWorkPort(Protocol): + def flush(self) -> None: ... + + def commit(self) -> None: ... + + def rollback(self) -> None: ... + + +def compute_order_revision( + collection_id: uuid.UUID, + items: Sequence[CollectionItemOrderSnapshot], +) -> str: + digest = hashlib.sha256() + digest.update(b"knowledge-collection-order-v1\x00") + digest.update(collection_id.bytes) + for item in sorted(items, key=lambda row: (row.rank, row.created_at, row.item_id)): + digest.update(item.item_id.bytes) + digest.update(str(item.rank).encode("ascii")) + digest.update(b"\x00") + return f"{ORDER_REVISION_PREFIX}{digest.hexdigest()}" + + +def validate_exact_reorder( + current: Sequence[CollectionItemOrderSnapshot], + requested: Sequence[CollectionItemRank], +) -> None: + if len(current) > MAX_REORDER_ITEMS: + raise CollectionStateConflict("item_reorder_limit_exceeded") + if len(current) != len(requested): + raise CollectionStateConflict("collection_order_stale") + + requested_ids = [row.item_id for row in requested] + requested_ranks = [row.rank for row in requested] + if len(set(requested_ids)) != len(requested_ids): + raise CollectionInputInvalid() + if len(set(requested_ranks)) != len(requested_ranks): + raise CollectionInputInvalid() + if set(requested_ids) != {row.item_id for row in current}: + raise CollectionStateConflict("collection_order_stale") + if sorted(requested_ranks) != list(range(len(requested_ranks))): + raise CollectionInputInvalid() + + +class CollectionLifecycleAndOrderUseCase: + def __init__( + self, + authorization: CollectionOperationAuthorizationPort, + repository: CollectionOperationRepositoryPort, + audit: CollectionOperationAuditPort, + unit_of_work: UnitOfWorkPort, + ) -> None: + self.authorization = authorization + self.repository = repository + self.audit = audit + self.unit_of_work = unit_of_work + + def archive( + self, command: CollectionOperationCommand + ) -> CollectionMutationResult: + collection = self._lock_collection(command) + if collection.lifecycle_state == "deleted": + self.unit_of_work.rollback() + raise CollectionHidden() + self._require_lifecycle_authority(command) + self._require_manual_collection(collection) + if collection.lifecycle_state == "archived": + self.unit_of_work.rollback() + return CollectionMutationResult("unchanged") + if collection.lifecycle_state != "active": + self.unit_of_work.rollback() + raise CollectionStateConflict("collection_lifecycle_conflict") + return self._change_lifecycle( + command, + before="active", + after="archived", + action="knowledge.collection.archived", + ) + + def restore( + self, command: CollectionOperationCommand + ) -> CollectionMutationResult: + collection = self._lock_collection(command) + if collection.lifecycle_state == "deleted": + self.unit_of_work.rollback() + raise CollectionHidden() + self._require_lifecycle_authority(command) + self._require_manual_collection(collection) + if collection.sync_state == "source_deleted": + self.unit_of_work.rollback() + raise CollectionStateConflict("source_deleted") + if collection.lifecycle_state == "active": + self.unit_of_work.rollback() + return CollectionMutationResult("unchanged") + if collection.lifecycle_state != "archived": + self.unit_of_work.rollback() + raise CollectionStateConflict("collection_lifecycle_conflict") + return self._change_lifecycle( + command, + before="archived", + after="active", + action="knowledge.collection.restored", + ) + + def reorder( + self, command: ReorderCollectionItemsCommand + ) -> CollectionOrderMutationResult: + collection = self._lock_collection(command) + if collection.lifecycle_state == "deleted": + self.unit_of_work.rollback() + raise CollectionHidden() + self._require_membership_authority(command, collection) + current = self.repository.lock_item_order( + command.organization_id, command.collection_id + ) + current_revision = compute_order_revision(command.collection_id, current) + if command.expected_order_revision != current_revision: + self.unit_of_work.rollback() + raise CollectionStateConflict("collection_order_stale") + try: + validate_exact_reorder(current, command.items) + except (CollectionInputInvalid, CollectionStateConflict): + self.unit_of_work.rollback() + raise + + current_ranks = {item.item_id: item.rank for item in current} + if all(current_ranks[item.item_id] == item.rank for item in command.items): + self.unit_of_work.rollback() + return CollectionOrderMutationResult("unchanged", current_revision) + + try: + requested_ranks = {rank.item_id: rank.rank for rank in command.items} + self.repository.set_item_ranks( + command.organization_id, + command.collection_id, + command.items, + ) + self.audit.record( + command=command, + action="knowledge.collection.items.reordered", + metadata={"item_count_bucket": _count_bucket(len(command.items))}, + ) + self.unit_of_work.flush() + updated = [ + CollectionItemOrderSnapshot( + item_id=item.item_id, + rank=requested_ranks[item.item_id], + created_at=item.created_at, + ) + for item in current + ] + revision = compute_order_revision(command.collection_id, updated) + self.unit_of_work.commit() + except (CollectionInputInvalid, CollectionStateConflict): + self.unit_of_work.rollback() + raise + except Exception as exc: + self.unit_of_work.rollback() + raise CollectionPersistenceFailed() from exc + return CollectionOrderMutationResult("changed", revision) + + def _lock_collection( + self, command: CollectionOperationCommand + ) -> CollectionSnapshot: + collection = self.repository.lock_collection( + command.organization_id, command.collection_id + ) + if collection is None: + self.unit_of_work.rollback() + raise CollectionHidden() + return collection + + def _require_lifecycle_authority( + self, command: CollectionOperationCommand + ) -> None: + if self.authorization.is_organization_manager( + command.actor_id, command.organization_id + ): + return + if self.authorization.has_domain_action( + command.actor_id, command.organization_id, "lifecycle_manage" + ): + return + if self.authorization.has_collection_action( + command.actor_id, + command.organization_id, + command.collection_id, + "manage", + ): + return + self.unit_of_work.rollback() + raise CollectionPermissionDenied() + + def _require_membership_authority( + self, + command: ReorderCollectionItemsCommand, + collection: CollectionSnapshot, + ) -> None: + self._require_manual_collection(collection) + if collection.lifecycle_state != "active": + self.unit_of_work.rollback() + raise CollectionStateConflict("collection_not_active") + is_manager = self.authorization.is_organization_manager( + command.actor_id, command.organization_id + ) + if collection.visibility == "public": + if not is_manager: + self.unit_of_work.rollback() + raise CollectionPermissionDenied() + if not command.acknowledged_public_runtime_exposure: + self.unit_of_work.rollback() + raise CollectionInputInvalid() + if collection.is_source_managed or self.repository.has_source_managed_items( + command.organization_id, + command.collection_id, + ): + self.unit_of_work.rollback() + raise CollectionPolicyBlocked("source_public_exposure_required") + return + if is_manager or self.authorization.has_domain_action( + command.actor_id, command.organization_id, "catalog_manage" + ): + return + if self.authorization.has_collection_action( + command.actor_id, + command.organization_id, + command.collection_id, + "manage", + ): + return + self.unit_of_work.rollback() + raise CollectionPermissionDenied() + + def _require_manual_collection(self, collection: CollectionSnapshot) -> None: + if collection.is_system_managed: + self.unit_of_work.rollback() + raise CollectionPolicyDenied() + + def _change_lifecycle( + self, + command: CollectionOperationCommand, + *, + before: str, + after: str, + action: str, + ) -> CollectionMutationResult: + try: + self.repository.set_lifecycle_state( + command.organization_id, command.collection_id, after + ) + self.audit.record( + command=command, + action=action, + before={"lifecycle_state": before}, + after={"lifecycle_state": after}, + ) + self.unit_of_work.flush() + self.unit_of_work.commit() + except Exception as exc: + self.unit_of_work.rollback() + raise CollectionPersistenceFailed() from exc + return CollectionMutationResult("changed") + + +def _count_bucket(value: int) -> str: + if value <= 0: + return "0" + if value == 1: + return "1" + if value <= 10: + return "2-10" + if value <= 50: + return "11-50" + if value <= 100: + return "51-100" + return "101+" diff --git a/apps/gateway/application/knowledge_administration/delegation_subjects.py b/apps/gateway/application/knowledge_administration/delegation_subjects.py new file mode 100644 index 000000000..e2602d8a2 --- /dev/null +++ b/apps/gateway/application/knowledge_administration/delegation_subjects.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import base64 +import hashlib +import re +import uuid +from dataclasses import dataclass +from typing import Literal + + +DelegationSubjectType = Literal["team", "user"] +MAX_SUBJECT_QUERY_LENGTH = 100 +MAX_SUBJECT_CURSOR_LENGTH = 128 +DEFAULT_SUBJECT_PAGE_SIZE = 25 +MAX_SUBJECT_PAGE_SIZE = 50 +_CURSOR_VERSION = 1 +_WHITESPACE_RE = re.compile(r"\s+") +_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]") +_PAGE_SIZE_RE = re.compile(r"^[0-9]+$") + + +class DelegationSubjectPageInvalid(Exception): + pass + + +@dataclass(frozen=True) +class DelegationSubjectCursor: + last_subject_id: uuid.UUID + + +def normalize_subject_query(value: str | None) -> str: + normalized = _WHITESPACE_RE.sub(" ", str(value or "").strip()) + if len(normalized) > MAX_SUBJECT_QUERY_LENGTH or _CONTROL_RE.search(normalized): + raise DelegationSubjectPageInvalid() + return normalized + + +def normalize_subject_type(value: str | None) -> DelegationSubjectType: + if value not in {"team", "user"}: + raise DelegationSubjectPageInvalid() + return value + + +def escape_like_prefix(value: str) -> str: + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +def encode_subject_cursor( + *, + subject_type: DelegationSubjectType, + query: str, + last_subject_id: uuid.UUID, +) -> str: + payload = bytes( + [_CURSOR_VERSION, 0 if subject_type == "team" else 1] + ) + _query_digest(query) + last_subject_id.bytes + return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=") + + +def decode_subject_cursor( + value: str | None, + *, + subject_type: DelegationSubjectType, + query: str, +) -> DelegationSubjectCursor | None: + if value is None: + return None + if not value or len(value) > MAX_SUBJECT_CURSOR_LENGTH: + raise DelegationSubjectPageInvalid() + try: + padding = "=" * (-len(value) % 4) + payload = base64.b64decode( + value + padding, + altchars=b"-_", + validate=True, + ) + except (ValueError, TypeError) as exc: + raise DelegationSubjectPageInvalid() from exc + if len(payload) != 26: + raise DelegationSubjectPageInvalid() + expected_kind = 0 if subject_type == "team" else 1 + if payload[0] != _CURSOR_VERSION or payload[1] != expected_kind: + raise DelegationSubjectPageInvalid() + if payload[2:10] != _query_digest(query): + raise DelegationSubjectPageInvalid() + return DelegationSubjectCursor(last_subject_id=uuid.UUID(bytes=payload[10:26])) + + +def validate_subject_page_size(limit: int | str) -> int: + if isinstance(limit, bool): + raise DelegationSubjectPageInvalid() + if isinstance(limit, int): + normalized = limit + elif isinstance(limit, str) and _PAGE_SIZE_RE.fullmatch(limit): + normalized = int(limit) + else: + raise DelegationSubjectPageInvalid() + if normalized < 1 or normalized > MAX_SUBJECT_PAGE_SIZE: + raise DelegationSubjectPageInvalid() + return normalized + + +def _query_digest(query: str) -> bytes: + return hashlib.sha256(query.casefold().encode("utf-8")).digest()[:8] diff --git a/apps/gateway/composition/knowledge_administration.py b/apps/gateway/composition/knowledge_administration.py index f47ddd166..97a26d5e1 100644 --- a/apps/gateway/composition/knowledge_administration.py +++ b/apps/gateway/composition/knowledge_administration.py @@ -1,8 +1,15 @@ from sqlalchemy.orm import Session +from apps.gateway.adapters.audit.knowledge_collection_operations import ( + SqlAlchemyKnowledgeCollectionOperationAudit, +) from apps.gateway.adapters.audit.knowledge_domain_permissions import ( SqlAlchemyKnowledgeDomainPermissionAudit, ) +from apps.gateway.adapters.db.knowledge_collection_operations import ( + SqlAlchemyKnowledgeCollectionOperationAuthorization, + SqlAlchemyKnowledgeCollectionOperationRepository, +) from apps.gateway.adapters.db.knowledge_domain_permissions import ( SqlAlchemyKnowledgeDomainAuthorization, SqlAlchemyKnowledgeDomainPermissionRepository, @@ -11,6 +18,9 @@ from apps.gateway.application.knowledge_administration.domain_permissions import ( KnowledgeDomainPermissionUseCase, ) +from apps.gateway.application.knowledge_administration.collection_operations import ( + CollectionLifecycleAndOrderUseCase, +) def build_knowledge_domain_permission_use_case( @@ -22,3 +32,14 @@ def build_knowledge_domain_permission_use_case( SqlAlchemyKnowledgeDomainPermissionAudit(db), SqlAlchemyUnitOfWork(db), ) + + +def build_knowledge_collection_lifecycle_and_order_use_case( + db: Session, +) -> CollectionLifecycleAndOrderUseCase: + return CollectionLifecycleAndOrderUseCase( + SqlAlchemyKnowledgeCollectionOperationAuthorization(db), + SqlAlchemyKnowledgeCollectionOperationRepository(db), + SqlAlchemyKnowledgeCollectionOperationAudit(db), + SqlAlchemyUnitOfWork(db), + ) diff --git a/apps/gateway/services/knowledge_collection_policy.py b/apps/gateway/services/knowledge_collection_policy.py index 5d5441c6b..a581fafc5 100644 --- a/apps/gateway/services/knowledge_collection_policy.py +++ b/apps/gateway/services/knowledge_collection_policy.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Literal from apps.shared.db.models.knowledge import KnowledgeCollection @@ -30,6 +30,20 @@ def bucket_count(count: int) -> str: return "1000+" +def bulk_permission_count_bucket( + count: int, +) -> Literal["0", "1", "2-10", "11-50"]: + if count < 0 or count > 50: + raise ValueError("bulk permission count must be between 0 and 50") + if count == 0: + return "0" + if count == 1: + return "1" + if count <= 10: + return "2-10" + return "11-50" + + def collection_visibility(collection: KnowledgeCollection) -> str: if (collection.safe_metadata or {}).get("visibility") == "public": return "public" diff --git a/apps/gateway/services/knowledge_collection_service.py b/apps/gateway/services/knowledge_collection_service.py index bcf0eefd2..ab9dd13e7 100644 --- a/apps/gateway/services/knowledge_collection_service.py +++ b/apps/gateway/services/knowledge_collection_service.py @@ -7,6 +7,20 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from apps.gateway.application.knowledge_administration.collection_operations import ( + MAX_REORDER_ITEMS, + CollectionItemOrderSnapshot, + compute_order_revision, +) +from apps.gateway.application.knowledge_administration.delegation_subjects import ( + DelegationSubjectPageInvalid, + decode_subject_cursor, + encode_subject_cursor, + escape_like_prefix, + normalize_subject_query, + normalize_subject_type, + validate_subject_page_size, +) from apps.gateway.services.audit_records import add_action_audit, add_data_change_audit from apps.shared.db.models.knowledge import ( KnowledgeBase, @@ -25,11 +39,13 @@ from apps.shared.schemas.knowledge import ( KnowledgeCollectionCreateRequest, KnowledgeCollectionItemLinkRequest, - KnowledgeCollectionItemReorderRequest, KnowledgeCollectionItemResponse, + KnowledgeCollectionItemsResponse, KnowledgeCollectionLinkCandidate, KnowledgeCollectionPermissionGrantRequest, KnowledgeCollectionPermissionBundleGrantRequest, + KnowledgeCollectionPermissionBulkBundleRequest, + KnowledgeCollectionPermissionBulkBundleResponse, KnowledgeCollectionPermissionResponse, KnowledgeCollectionResponse, KnowledgeCollectionUpdateRequest, @@ -46,12 +62,12 @@ from apps.shared.services.permissions import ( get_effective_knowledge_domain_actions, get_effective_knowledge_base_auth_state, - has_active_organization_membership, has_knowledge_base_permission, has_organization_manager_permission, ) from apps.gateway.services.knowledge_collection_policy import ( bucket_count, + bulk_permission_count_bucket, collection_visibility, normalize_optional_text, normalize_required_text, @@ -138,6 +154,7 @@ def list_collections( decisions = self.permission_helper.bulk_evaluate_collection_action( collections, "read", + include_archived=True, ) responses: list[KnowledgeCollectionResponse] = [] for collection in collections: @@ -243,47 +260,44 @@ def update_collection( self.db.refresh(collection) return self._collection_response(collection) - def archive_collection(self, collection_id: uuid.UUID) -> None: - collection = self._collection_or_hidden(collection_id) - if not self._has_domain_action("lifecycle_manage"): - self._require_collection_action(collection, "manage") - if collection.is_system_managed: - raise KnowledgeCollectionServiceError( - 403, - "policy.denied", - "System-managed collections cannot be manually archived.", - ) - collection.lifecycle_state = "archived" - collection.updated_at = self._now() - self._record_collection_audit_and_commit( - "knowledge.collection.archived", - collection, - ) - def list_items(self, collection_id: uuid.UUID) -> list[KnowledgeCollectionItemResponse]: + return self.list_items_response(collection_id).items + + def list_items_response( + self, collection_id: uuid.UUID + ) -> KnowledgeCollectionItemsResponse: collection = self._collection_or_hidden(collection_id) if not ( self._visibility(collection) == "private" and self._has_domain_action("catalog_manage") ): self._require_collection_action(collection, "read") - items = ( - self.db.query(KnowledgeCollectionItem) - .filter( - KnowledgeCollectionItem.organization_id == self.organization_id, - KnowledgeCollectionItem.collection_id == collection.id, - ) - .order_by(KnowledgeCollectionItem.rank.asc(), KnowledgeCollectionItem.created_at.asc()) - .all() + return self._items_response(collection.id, self._ordered_collection_items(collection.id)) + + def list_items_management_response( + self, collection_id: uuid.UUID + ) -> KnowledgeCollectionItemsResponse: + """Return the safe item projection after an authorized membership mutation. + + Collection actions are independent additive grants, so a caller may hold + ``manage`` without ``read``. Rechecking the mutation authority avoids a + committed write being reported as a failed API response while preserving + the stricter ``collection.read`` contract of the standalone GET endpoint. + """ + + collection = self._collection_or_hidden(collection_id) + self._require_collection_membership_candidate_access(collection) + return self._items_response( + collection.id, + self._ordered_collection_items(collection.id), ) - return [self._item_response(item) for item in items] def link_item( self, collection_id: uuid.UUID, request: KnowledgeCollectionItemLinkRequest, - ) -> KnowledgeCollectionItemResponse: - collection = self._collection_or_hidden(collection_id) + ) -> KnowledgeCollectionItemsResponse: + collection = self._locked_collection_or_hidden(collection_id) kb = self._knowledge_base_or_hidden(request.knowledge_base_id) self._require_collection_membership_mutation( collection, @@ -300,23 +314,23 @@ def link_item( "Resource not found.", ) - existing = ( - self.db.query(KnowledgeCollectionItem) - .filter( - KnowledgeCollectionItem.collection_id == collection.id, - KnowledgeCollectionItem.knowledge_base_id == kb.id, - ) - .first() + items = self._locked_collection_items(collection.id) + existing = next( + (item for item in items if item.knowledge_base_id == kb.id), + None, ) if existing is not None: - return self._item_response(existing) + self.db.rollback() + return self.list_items_management_response(collection.id) + + self._normalize_item_ranks(items) item = KnowledgeCollectionItem( id=uuid.uuid4(), organization_id=self.organization_id, collection_id=collection.id, knowledge_base_id=kb.id, - rank=request.rank, + rank=len(items), safe_metadata={}, ) self.db.add(item) @@ -324,7 +338,7 @@ def link_item( self._record_collection_audit( "knowledge.collection.item.linked", collection, - metadata={"knowledge_base_id": str(kb.id)}, + metadata={"item_change": "linked"}, ) self.db.commit() except IntegrityError: @@ -338,13 +352,12 @@ def link_item( .first() ) if existing is not None: - return self._item_response(existing) + return self.list_items_management_response(collection.id) raise except Exception: self.db.rollback() raise - self.db.refresh(item) - return self._item_response(item) + return self.list_items_management_response(collection.id) def unlink_item( self, @@ -353,8 +366,14 @@ def unlink_item( *, acknowledged_public_runtime_exposure: bool = False, ) -> None: - collection = self._collection_or_hidden(collection_id) - item = self._item_or_hidden(collection.id, item_id) + collection = self._locked_collection_or_hidden(collection_id) + items = self._locked_collection_items(collection.id) + item = next((row for row in items if row.id == item_id), None) + if item is None: + self.db.rollback() + raise KnowledgeCollectionServiceError( + 404, "resource.hidden", "Resource not found." + ) kb = self._knowledge_base_or_hidden(item.knowledge_base_id) self._require_collection_membership_mutation( collection, @@ -364,46 +383,13 @@ def unlink_item( ), ) self.db.delete(item) + self._normalize_item_ranks([row for row in items if row.id != item.id]) self._record_collection_audit_and_commit( "knowledge.collection.item.unlinked", collection, - metadata={"knowledge_base_id": str(kb.id)}, + metadata={"item_change": "unlinked"}, ) - def reorder_items( - self, - collection_id: uuid.UUID, - request: KnowledgeCollectionItemReorderRequest, - ) -> list[KnowledgeCollectionItemResponse]: - collection = self._collection_or_hidden(collection_id) - self._require_collection_membership_mutation( - collection, - acknowledged_public_runtime_exposure=( - request.acknowledged_public_runtime_exposure - ), - ) - ranks = {entry.item_id: entry.rank for entry in request.items} - items = ( - self.db.query(KnowledgeCollectionItem) - .filter( - KnowledgeCollectionItem.organization_id == self.organization_id, - KnowledgeCollectionItem.collection_id == collection.id, - KnowledgeCollectionItem.id.in_(ranks.keys()), - ) - .all() - ) - if len(items) != len(ranks): - raise KnowledgeCollectionServiceError(404, "resource.hidden", "Resource not found.") - for item in items: - item.rank = ranks[item.id] - self._record_collection_audit_and_commit( - "knowledge.collection.items.reordered", - collection, - ) - for item in items: - self.db.refresh(item) - return self.list_items(collection.id) - def list_link_candidates( self, collection_id: uuid.UUID, @@ -512,56 +498,132 @@ def list_permissions( def list_delegation_subjects( self, collection_id: uuid.UUID, + *, + subject_type: str | None, + query: str | None = None, + cursor: str | None = None, + limit: int | str = 25, ) -> KnowledgeDelegationSubjectsResponse: collection = self._collection_or_hidden(collection_id) self._require_collection_permission_authority(collection) - return self._delegation_subjects_response() + return self._delegation_subjects_response( + subject_type=subject_type, + query=query, + cursor=cursor, + limit=limit, + ) - def list_domain_delegation_subjects(self) -> KnowledgeDelegationSubjectsResponse: + def list_domain_delegation_subjects( + self, + *, + subject_type: str | None, + query: str | None = None, + cursor: str | None = None, + limit: int | str = 25, + ) -> KnowledgeDelegationSubjectsResponse: self._require_org_manager() - return self._delegation_subjects_response() + return self._delegation_subjects_response( + subject_type=subject_type, + query=query, + cursor=cursor, + limit=limit, + ) - def _delegation_subjects_response(self) -> KnowledgeDelegationSubjectsResponse: - teams = ( - self.db.query(Team) - .filter( + def _delegation_subjects_response( + self, + *, + subject_type: str | None, + query: str | None, + cursor: str | None, + limit: int | str, + ) -> KnowledgeDelegationSubjectsResponse: + try: + normalized_subject_type = normalize_subject_type(subject_type) + normalized_query = normalize_subject_query(query) + requested_limit = validate_subject_page_size(limit) + decoded_cursor = decode_subject_cursor( + cursor, + subject_type=normalized_subject_type, + query=normalized_query, + ) + except DelegationSubjectPageInvalid as exc: + raise KnowledgeCollectionServiceError( + 400, + "validation.failed", + "Delegation subject page request is invalid.", + ) from exc + + if normalized_subject_type == "team": + subject_query = self.db.query(Team).filter( Team.organization_id == self.organization_id, Team.is_active.is_(True), ) - .order_by(Team.name.asc(), Team.id.asc()) - .all() - ) - users = ( - self.db.query(User) - .join( - OrganizationMembership, - OrganizationMembership.user_id == User.id, + if normalized_query: + subject_query = subject_query.filter( + Team.name.ilike( + f"{escape_like_prefix(normalized_query)}%", + escape="\\", + ) + ) + if decoded_cursor is not None: + subject_query = subject_query.filter( + Team.id > decoded_cursor.last_subject_id + ) + rows = subject_query.order_by(Team.id.asc()).limit(requested_limit + 1).all() + else: + subject_query = ( + self.db.query(User) + .join( + OrganizationMembership, + OrganizationMembership.user_id == User.id, + ) + .filter( + OrganizationMembership.organization_id == self.organization_id, + OrganizationMembership.membership_state == "active", + User.deactivated_at.is_(None), + ) ) - .filter( - OrganizationMembership.organization_id == self.organization_id, - OrganizationMembership.membership_state == "active", - User.deactivated_at.is_(None), + if normalized_query: + subject_query = subject_query.filter( + User.name.ilike( + f"{escape_like_prefix(normalized_query)}%", + escape="\\", + ) + ) + if decoded_cursor is not None: + subject_query = subject_query.filter( + User.id > decoded_cursor.last_subject_id + ) + rows = subject_query.order_by(User.id.asc()).limit(requested_limit + 1).all() + + has_more = len(rows) > requested_limit + page = rows[:requested_limit] + next_cursor = ( + encode_subject_cursor( + subject_type=normalized_subject_type, + query=normalized_query, + last_subject_id=page[-1].id, ) - .order_by(User.name.asc(), User.id.asc()) - .all() + if has_more and page + else None ) return KnowledgeDelegationSubjectsResponse( - teams=[ + subjects=[ KnowledgeDelegationSubject( - subject_type="team", - subject_id=team.id, - subject_safe_label=str(team.name or "Team"), - ) - for team in teams - ], - users=[ - KnowledgeDelegationSubject( - subject_type="user", - subject_id=user.id, - subject_safe_label=str(user.name or "User"), + subject_type=normalized_subject_type, + subject_id=subject.id, + subject_safe_label=( + safe_label_from_text(getattr(subject, "name", None)) + or ( + "Team" + if normalized_subject_type == "team" + else "User" + ) + ), ) - for user in users + for subject in page ], + next_cursor=next_cursor, ) def grant_permission( @@ -569,7 +631,7 @@ def grant_permission( collection_id: uuid.UUID, request: KnowledgeCollectionPermissionGrantRequest, ) -> KnowledgeCollectionPermissionResponse: - collection = self._collection_or_hidden(collection_id) + collection = self._locked_collection_or_hidden(collection_id) authority = self._require_collection_permission_authority(collection) self._block_collection_delegate_self_escalation( collection, @@ -579,6 +641,7 @@ def grant_permission( if request.subject_type == "team": row, created = self._grant_team_permission(collection, request) if not created: + self.db.rollback() return self._team_permission_response(row) self._record_collection_audit_and_commit( "knowledge.collection.permission.granted", @@ -592,6 +655,7 @@ def grant_permission( return self._team_permission_response(row) row, created = self._grant_user_permission(collection, request) if not created: + self.db.rollback() return self._user_permission_response(row) self._record_collection_audit_and_commit( "knowledge.collection.permission.granted", @@ -609,7 +673,7 @@ def grant_permission_bundle( collection_id: uuid.UUID, request: KnowledgeCollectionPermissionBundleGrantRequest, ) -> list[KnowledgeCollectionPermissionResponse]: - collection = self._collection_or_hidden(collection_id) + collection = self._locked_collection_or_hidden(collection_id) authority = self._require_collection_permission_authority(collection) actions = COLLECTION_ROLE_BUNDLE_ACTIONS[request.role_bundle] escalation_probe = KnowledgeCollectionPermissionGrantRequest( @@ -667,13 +731,214 @@ def grant_permission_bundle( raise for row in created_rows: self.db.refresh(row) + else: + self.db.rollback() if request.subject_type == "team": return [self._team_permission_response(row) for row in rows] return [self._user_permission_response(row) for row in rows] + def revoke_permission_bundle( + self, + collection_id: uuid.UUID, + request: KnowledgeCollectionPermissionBundleGrantRequest, + ) -> None: + collection = self._locked_collection_or_hidden(collection_id) + self._require_collection_permission_authority(collection) + actions = COLLECTION_ROLE_BUNDLE_ACTIONS[request.role_bundle] + model, subject_column = self._collection_permission_model_and_subject_column( + request.subject_type + ) + rows = ( + self.db.query(model) + .filter( + model.grantee_organization_id == self.organization_id, + model.knowledge_collection_id == collection.id, + subject_column == request.subject_id, + model.permission_action.in_(actions), + ) + .with_for_update() + .all() + ) + if not rows: + self.db.rollback() + return + manage_row = next( + (row for row in rows if row.permission_action == "manage"), + None, + ) + if manage_row is not None and self._would_revoke_current_user_last_manage_path( + collection.id, + request.subject_type, + manage_row, + ): + self.db.rollback() + raise KnowledgeCollectionServiceError( + 403, + "permission.denied", + "Cannot revoke your own last management path.", + ) + for row in rows: + self.db.delete(row) + self._record_collection_audit_and_commit( + "knowledge.collection.permission_bundle.revoked", + collection, + metadata={ + "subject_type": request.subject_type, + "role_bundle": request.role_bundle, + "permission_actions": list(actions), + }, + ) + + def mutate_permission_bundle_bulk( + self, + request: KnowledgeCollectionPermissionBulkBundleRequest, + ) -> KnowledgeCollectionPermissionBulkBundleResponse: + collection_ids = sorted(request.collection_ids, key=str) + collections = ( + self.db.query(KnowledgeCollection) + .filter( + KnowledgeCollection.organization_id == self.organization_id, + KnowledgeCollection.id.in_(collection_ids), + KnowledgeCollection.lifecycle_state != "deleted", + ) + .order_by(KnowledgeCollection.id.asc()) + .with_for_update() + .all() + ) + if len(collections) != len(collection_ids): + self.db.rollback() + raise KnowledgeCollectionServiceError( + 404, "resource.hidden", "Resource not found." + ) + + authority = self._require_bulk_collection_permission_authority(collections) + if request.operation == "grant" and authority == "domain_delegate": + probe = KnowledgeCollectionPermissionGrantRequest( + subject_type=request.subject_type, + subject_id=request.subject_id, + permission_action=COLLECTION_ROLE_BUNDLE_ACTIONS[ + request.role_bundle + ][0], + ) + self._block_collection_delegate_self_escalation( + collections[0], + probe, + authority=authority, + ) + + self._lock_bundle_subject( + request.subject_type, + request.subject_id, + require_active=request.operation == "grant", + ) + actions = COLLECTION_ROLE_BUNDLE_ACTIONS[request.role_bundle] + model, subject_column = self._collection_permission_model_and_subject_column( + request.subject_type + ) + existing_rows = ( + self.db.query(model) + .filter( + model.grantee_organization_id == self.organization_id, + model.knowledge_collection_id.in_(collection_ids), + subject_column == request.subject_id, + model.permission_action.in_(actions), + ) + .order_by(model.knowledge_collection_id.asc(), model.permission_action.asc()) + .with_for_update() + .all() + ) + existing = { + (row.knowledge_collection_id, row.permission_action): row + for row in existing_rows + } + + if request.operation == "revoke" and "manage" in actions: + self._require_no_bulk_last_manage_violation( + collection_ids, + subject_type=request.subject_type, + subject_id=request.subject_id, + planned_rows=[ + row + for row in existing_rows + if row.permission_action == "manage" + ], + ) + + changed_collection_ids: set[uuid.UUID] = set() + if request.operation == "grant": + for collection in collections: + for action in actions: + key = (collection.id, action) + if key in existing: + continue + row = model( + id=uuid.uuid4(), + grantee_organization_id=self.organization_id, + assigned_by=self.user_id, + knowledge_collection_id=collection.id, + permission_action=action, + **{ + "team_id" if request.subject_type == "team" else "user_id": ( + request.subject_id + ) + }, + ) + self.db.add(row) + changed_collection_ids.add(collection.id) + else: + for row in existing_rows: + changed_collection_ids.add(row.knowledge_collection_id) + self.db.delete(row) + + changed_count = len(changed_collection_ids) + try: + response = KnowledgeCollectionPermissionBulkBundleResponse( + operation=request.operation, + subject_type=request.subject_type, + role_bundle=request.role_bundle, + target_count_bucket=bulk_permission_count_bucket(len(collections)), + changed_count_bucket=bulk_permission_count_bucket(changed_count), + unchanged_count_bucket=bulk_permission_count_bucket( + len(collections) - changed_count + ), + ) + if not changed_collection_ids: + self.db.rollback() + else: + for collection in collections: + if collection.id not in changed_collection_ids: + continue + self._record_collection_audit( + ( + "knowledge.collection.permission_bundle.granted" + if request.operation == "grant" + else "knowledge.collection.permission_bundle.revoked" + ), + collection, + metadata={ + "subject_type": request.subject_type, + "role_bundle": request.role_bundle, + "permission_actions": list(actions), + "bulk_operation": True, + }, + ) + self.db.commit() + except IntegrityError as exc: + self.db.rollback() + raise KnowledgeCollectionServiceError( + 409, + "conflict", + "Knowledge Collection permissions changed concurrently.", + ) from exc + except Exception: + self.db.rollback() + raise + + return response + def revoke_permission(self, collection_id: uuid.UUID, permission_id: uuid.UUID) -> None: - collection = self._collection_or_hidden(collection_id) + collection = self._locked_collection_or_hidden(collection_id) self._require_collection_permission_authority(collection) row = ( self.db.query(TeamKnowledgeCollectionPermission) @@ -684,6 +949,7 @@ def revoke_permission(self, collection_id: uuid.UUID, permission_id: uuid.UUID) TeamKnowledgeCollectionPermission.knowledge_collection_id == collection.id, ) + .with_for_update() .first() ) subject_type = "team" @@ -697,6 +963,7 @@ def revoke_permission(self, collection_id: uuid.UUID, permission_id: uuid.UUID) UserKnowledgeCollectionPermission.knowledge_collection_id == collection.id, ) + .with_for_update() .first() ) subject_type = "user" @@ -728,8 +995,15 @@ def update_visibility( collection_id: uuid.UUID, request: KnowledgeCollectionVisibilityRequest, ) -> KnowledgeCollectionVisibilityResponse: - collection = self._collection_or_hidden(collection_id) + collection = self._locked_collection_or_hidden(collection_id) self._require_org_manager() + if collection.lifecycle_state != "active": + self.db.rollback() + raise KnowledgeCollectionServiceError( + 409, + "conflict", + "Archived collections must be restored before changing visibility.", + ) if request.visibility == "public" and not request.acknowledged_public_runtime_exposure: raise KnowledgeCollectionServiceError( 400, @@ -741,6 +1015,7 @@ def update_visibility( request.visibility == "public" and ( getattr(collection, "source_identity_id", None) is not None + or bool(getattr(collection, "source_connector_ref", None)) or self._collection_has_source_managed_items(collection.id) ) ): @@ -752,6 +1027,14 @@ def update_visibility( ) metadata = dict(collection.safe_metadata or {}) + if self._visibility(collection) == request.visibility: + self.db.rollback() + response = self.get_collection(collection_id) + return KnowledgeCollectionVisibilityResponse( + collection=response, + linked_kb_count_bucket=response.linked_kb_count_bucket, + active_kb_count_bucket=response.active_kb_count_bucket, + ) metadata["visibility"] = request.visibility collection.safe_metadata = metadata collection.updated_at = self._now() @@ -780,6 +1063,7 @@ def _grant_team_permission( Team.organization_id == self.organization_id, Team.is_active.is_(True), ) + .with_for_update() .first() ) if team is None: @@ -795,6 +1079,7 @@ def _grant_team_permission( TeamKnowledgeCollectionPermission.permission_action == request.permission_action, ) + .with_for_update() .first() ) if row is not None: @@ -815,18 +1100,25 @@ def _grant_user_permission( collection: KnowledgeCollection, request: KnowledgeCollectionPermissionGrantRequest, ) -> tuple[UserKnowledgeCollectionPermission, bool]: - user = ( - self.db.query(User) - .filter(User.id == request.subject_id, User.deactivated_at.is_(None)) + row = ( + self.db.query(User, OrganizationMembership) + .join( + OrganizationMembership, + OrganizationMembership.user_id == User.id, + ) + .filter( + User.id == request.subject_id, + User.deactivated_at.is_(None), + OrganizationMembership.organization_id == self.organization_id, + OrganizationMembership.membership_state == "active", + ) + .with_for_update() .first() ) - if user is None or not has_active_organization_membership( - self.db, - user.id, - self.organization_id, - ): + if row is None: raise KnowledgeCollectionServiceError(404, "resource.hidden", "Resource not found.") - row = ( + user = row[0] + permission_row = ( self.db.query(UserKnowledgeCollectionPermission) .filter( UserKnowledgeCollectionPermission.grantee_organization_id @@ -837,11 +1129,12 @@ def _grant_user_permission( UserKnowledgeCollectionPermission.permission_action == request.permission_action, ) + .with_for_update() .first() ) - if row is not None: - return row, False - row = UserKnowledgeCollectionPermission( + if permission_row is not None: + return permission_row, False + permission_row = UserKnowledgeCollectionPermission( id=uuid.uuid4(), grantee_organization_id=self.organization_id, user_id=user.id, @@ -849,8 +1142,8 @@ def _grant_user_permission( knowledge_collection_id=collection.id, permission_action=request.permission_action, ) - self.db.add(row) - return row, True + self.db.add(permission_row) + return permission_row, True def _collection_response( self, @@ -860,6 +1153,7 @@ def _collection_response( action: self.permission_helper.evaluate_collection_action( collection, action, + include_archived=True, ).allowed for action in ("read", "route", "manage", "sync") } @@ -912,6 +1206,69 @@ def _item_response( can_use_kb=use_decision.allowed, ) + def _items_response( + self, + collection_id: uuid.UUID, + items: list[KnowledgeCollectionItem], + ) -> KnowledgeCollectionItemsResponse: + snapshots = [ + CollectionItemOrderSnapshot( + item_id=item.id, + rank=item.rank, + created_at=item.created_at, + ) + for item in items + ] + reorder_supported = len(items) <= MAX_REORDER_ITEMS + return KnowledgeCollectionItemsResponse( + items=[self._item_response(item) for item in items], + order_revision=compute_order_revision(collection_id, snapshots), + reorder_supported=reorder_supported, + safe_reason_code=( + None if reorder_supported else "item_reorder_limit_exceeded" + ), + ) + + def _ordered_collection_items( + self, collection_id: uuid.UUID + ) -> list[KnowledgeCollectionItem]: + return ( + self.db.query(KnowledgeCollectionItem) + .filter( + KnowledgeCollectionItem.organization_id == self.organization_id, + KnowledgeCollectionItem.collection_id == collection_id, + ) + .order_by( + KnowledgeCollectionItem.rank.asc(), + KnowledgeCollectionItem.created_at.asc(), + KnowledgeCollectionItem.id.asc(), + ) + .all() + ) + + def _locked_collection_items( + self, collection_id: uuid.UUID + ) -> list[KnowledgeCollectionItem]: + return ( + self.db.query(KnowledgeCollectionItem) + .filter( + KnowledgeCollectionItem.organization_id == self.organization_id, + KnowledgeCollectionItem.collection_id == collection_id, + ) + .order_by( + KnowledgeCollectionItem.rank.asc(), + KnowledgeCollectionItem.created_at.asc(), + KnowledgeCollectionItem.id.asc(), + ) + .with_for_update() + .all() + ) + + @staticmethod + def _normalize_item_ranks(items: list[KnowledgeCollectionItem]) -> None: + for rank, item in enumerate(items): + item.rank = rank + def _team_permission_response( self, row: TeamKnowledgeCollectionPermission, @@ -921,7 +1278,9 @@ def _team_permission_response( permission_id=row.id, subject_type="team", subject_id=row.team_id, - subject_safe_label=getattr(team, "name", None), + subject_safe_label=( + safe_label_from_text(getattr(team, "name", None)) or "Team" + ), permission_action=row.permission_action, ) @@ -934,7 +1293,9 @@ def _user_permission_response( permission_id=row.id, subject_type="user", subject_id=row.user_id, - subject_safe_label=getattr(user, "name", None), + subject_safe_label=( + safe_label_from_text(getattr(user, "name", None)) or "User" + ), permission_action=row.permission_action, ) @@ -1011,6 +1372,13 @@ def _require_collection_membership_mutation( acknowledged_public_runtime_exposure: bool = False, adds_public_exposure: bool = False, ) -> None: + if collection.lifecycle_state != "active": + self.db.rollback() + raise KnowledgeCollectionServiceError( + 409, + "conflict", + "Archived collections must be restored before changing membership.", + ) if collection.is_system_managed: raise KnowledgeCollectionServiceError( 403, @@ -1026,7 +1394,11 @@ def _require_collection_membership_mutation( "Public membership acknowledgement is required.", {"field": "acknowledged_public_runtime_exposure"}, ) - if adds_public_exposure and kb is not None and self._is_source_managed_kb(kb): + if adds_public_exposure and ( + getattr(collection, "source_identity_id", None) is not None + or bool(getattr(collection, "source_connector_ref", None)) + or (kb is not None and self._is_source_managed_kb(kb)) + ): raise KnowledgeCollectionServiceError( 409, "policy.blocked", @@ -1060,6 +1432,7 @@ def _require_collection_permission_authority( decision = self.permission_helper.evaluate_collection_action( collection, "manage", + include_archived=True, ) if decision.allowed: return "resource_manager" @@ -1072,6 +1445,155 @@ def _require_collection_permission_authority( "Resource not found." if status_code == 404 else "Permission denied.", ) + def _require_bulk_collection_permission_authority( + self, + collections: list[KnowledgeCollection], + ) -> str: + if self._is_org_manager(): + return "organization_manager" + decisions = self.permission_helper.bulk_evaluate_collection_action( + collections, + "manage", + include_archived=True, + ) + denied = [ + decisions.get(collection.id) + for collection in collections + if not decisions.get(collection.id) + or not decisions[collection.id].allowed + ] + if not denied: + return "resource_manager" + if self._has_domain_action("permission_delegate"): + return "domain_delegate" + self.db.rollback() + hidden = any( + getattr(decision, "external_reason_code", "resource.hidden") + == "resource.hidden" + for decision in denied + ) + raise KnowledgeCollectionServiceError( + 404 if hidden else 403, + "resource.hidden" if hidden else "permission.denied", + "Resource not found." if hidden else "Permission denied.", + ) + + @staticmethod + def _collection_permission_model_and_subject_column(subject_type: str): + if subject_type == "team": + return TeamKnowledgeCollectionPermission, TeamKnowledgeCollectionPermission.team_id + return UserKnowledgeCollectionPermission, UserKnowledgeCollectionPermission.user_id + + def _lock_bundle_subject( + self, + subject_type: str, + subject_id: uuid.UUID, + *, + require_active: bool, + ) -> None: + if not require_active: + return + if subject_type == "team": + subject = ( + self.db.query(Team) + .filter( + Team.id == subject_id, + Team.organization_id == self.organization_id, + Team.is_active.is_(True), + ) + .with_for_update() + .first() + ) + else: + subject = ( + self.db.query(User, OrganizationMembership) + .join( + OrganizationMembership, + OrganizationMembership.user_id == User.id, + ) + .filter( + User.id == subject_id, + User.deactivated_at.is_(None), + OrganizationMembership.organization_id == self.organization_id, + OrganizationMembership.membership_state == "active", + ) + .with_for_update() + .first() + ) + if subject is None: + self.db.rollback() + raise KnowledgeCollectionServiceError( + 404, "resource.hidden", "Resource not found." + ) + + def _require_no_bulk_last_manage_violation( + self, + collection_ids: list[uuid.UUID], + *, + subject_type: str, + subject_id: uuid.UUID, + planned_rows: list[ + TeamKnowledgeCollectionPermission | UserKnowledgeCollectionPermission + ], + ) -> None: + if self._is_org_manager() or not planned_rows: + return + active_team_ids = self._active_team_ids() + targets_actor = ( + subject_type == "user" and subject_id == self.user_id + ) or ( + subject_type == "team" and subject_id in active_team_ids + ) + if not targets_actor: + return + + planned_ids = {row.id for row in planned_rows} + direct_rows = ( + self.db.query(UserKnowledgeCollectionPermission) + .filter( + UserKnowledgeCollectionPermission.grantee_organization_id + == self.organization_id, + UserKnowledgeCollectionPermission.user_id == self.user_id, + UserKnowledgeCollectionPermission.knowledge_collection_id.in_( + collection_ids + ), + UserKnowledgeCollectionPermission.permission_action == "manage", + ) + .with_for_update() + .all() + ) + team_rows: list[TeamKnowledgeCollectionPermission] = [] + if active_team_ids: + team_rows = ( + self.db.query(TeamKnowledgeCollectionPermission) + .filter( + TeamKnowledgeCollectionPermission.grantee_organization_id + == self.organization_id, + TeamKnowledgeCollectionPermission.team_id.in_(active_team_ids), + TeamKnowledgeCollectionPermission.knowledge_collection_id.in_( + collection_ids + ), + TeamKnowledgeCollectionPermission.permission_action == "manage", + ) + .with_for_update() + .all() + ) + alternate_collection_ids = { + row.knowledge_collection_id + for row in [*direct_rows, *team_rows] + if row.id not in planned_ids + } + if any( + row.knowledge_collection_id not in alternate_collection_ids + for row in planned_rows + ): + self.db.rollback() + raise KnowledgeCollectionServiceError( + 403, + "permission.denied", + "Cannot revoke your own last management path.", + ) + def _block_collection_delegate_self_escalation( self, collection: KnowledgeCollection, @@ -1113,7 +1635,11 @@ def _require_collection_action( collection: KnowledgeCollection, action: str, ) -> None: - decision = self.permission_helper.evaluate_collection_action(collection, action) + decision = self.permission_helper.evaluate_collection_action( + collection, + action, + include_archived=True, + ) if not decision.allowed: status_code = 404 if decision.external_reason_code == "resource.hidden" else 403 raise KnowledgeCollectionServiceError( @@ -1181,6 +1707,26 @@ def _collection_or_hidden(self, collection_id: uuid.UUID) -> KnowledgeCollection raise KnowledgeCollectionServiceError(404, "resource.hidden", "Resource not found.") return collection + def _locked_collection_or_hidden( + self, collection_id: uuid.UUID + ) -> KnowledgeCollection: + collection = ( + self.db.query(KnowledgeCollection) + .filter( + KnowledgeCollection.id == collection_id, + KnowledgeCollection.organization_id == self.organization_id, + KnowledgeCollection.lifecycle_state != "deleted", + ) + .with_for_update() + .first() + ) + if collection is None: + self.db.rollback() + raise KnowledgeCollectionServiceError( + 404, "resource.hidden", "Resource not found." + ) + return collection + def _item_or_hidden( self, collection_id: uuid.UUID, diff --git a/apps/gateway/tests/api/test_knowledge_collection_api.py b/apps/gateway/tests/api/test_knowledge_collection_api.py index 90d972879..673f1c2d9 100644 --- a/apps/gateway/tests/api/test_knowledge_collection_api.py +++ b/apps/gateway/tests/api/test_knowledge_collection_api.py @@ -7,11 +7,15 @@ from apps.gateway.api.v1.endpoints import knowledge as knowledge_endpoint from apps.gateway.auth.dependencies import get_current_user from apps.gateway.main import app +from apps.gateway.application.knowledge_administration.collection_operations import ( + CollectionStateConflict, +) from apps.gateway.services.knowledge_collection_service import ( KnowledgeCollectionServiceError, ) -from apps.shared.schemas.knowledge import KnowledgeCollectionLLMSelectableResponse from apps.shared.schemas.knowledge import ( + KnowledgeCollectionItemsResponse, + KnowledgeCollectionLLMSelectableResponse, KnowledgeCollectionResponse, KnowledgeCollectionVisibilityResponse, ) @@ -260,3 +264,136 @@ def update_visibility(self, collection_id, request): assert body["collection"]["visibility"] == "public" assert body["public_runtime_effect"] == "anonymous_public_only_candidate" assert "exact_denied_count" not in str(body) + + +def test_collection_restore_route_passes_active_organization_to_use_case(monkeypatch): + organization_id = uuid.uuid4() + user_id = uuid.uuid4() + collection_id = uuid.uuid4() + captured = {} + + monkeypatch.setattr( + knowledge_endpoint, + "resolve_active_organization_id", + lambda db, request, raw, current_user_id: organization_id, + ) + + class FakeUseCase: + def restore(self, command): + captured["command"] = command + + monkeypatch.setattr( + knowledge_endpoint, + "build_knowledge_collection_lifecycle_and_order_use_case", + lambda db: FakeUseCase(), + ) + app.dependency_overrides[knowledge_endpoint.get_db] = lambda: object() + app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(id=user_id) + try: + response = TestClient(app).post( + f"/api/v1/knowledge/collections/{collection_id}/restore", + headers={"X-Organization-Id": str(organization_id)}, + ) + finally: + app.dependency_overrides = {} + + assert response.status_code == 204 + assert captured["command"].actor_id == user_id + assert captured["command"].organization_id == organization_id + assert captured["command"].collection_id == collection_id + + +def test_collection_reorder_stale_revision_uses_safe_conflict_envelope(monkeypatch): + organization_id = uuid.uuid4() + user_id = uuid.uuid4() + collection_id = uuid.uuid4() + + monkeypatch.setattr( + knowledge_endpoint, + "resolve_active_organization_id", + lambda db, request, raw, current_user_id: organization_id, + ) + + class FakeUseCase: + def reorder(self, command): + raise CollectionStateConflict("collection_order_stale") + + monkeypatch.setattr( + knowledge_endpoint, + "build_knowledge_collection_lifecycle_and_order_use_case", + lambda db: FakeUseCase(), + ) + app.dependency_overrides[knowledge_endpoint.get_db] = lambda: object() + app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(id=user_id) + try: + response = TestClient(app).patch( + f"/api/v1/knowledge/collections/{collection_id}/items/reorder", + json={ + "items": [{"item_id": str(uuid.uuid4()), "rank": 0}], + "expected_order_revision": f"ord_v1_{'0' * 64}", + }, + headers={"X-Organization-Id": str(organization_id)}, + ) + finally: + app.dependency_overrides = {} + + assert response.status_code == 409 + body = response.json() + assert body["error"]["code"] == "conflict" + assert body["error"]["details"] == {"reason": "collection_order_stale"} + assert collection_id.hex not in str(body) + + +def test_empty_collection_reorder_returns_management_projection(monkeypatch): + organization_id = uuid.uuid4() + user_id = uuid.uuid4() + collection_id = uuid.uuid4() + revision = f"ord_v1_{'0' * 64}" + captured = {} + + monkeypatch.setattr( + knowledge_endpoint, + "resolve_active_organization_id", + lambda db, request, raw, current_user_id: organization_id, + ) + + class FakeUseCase: + def reorder(self, command): + captured["command"] = command + + class FakeService: + def __init__(self, db, *, user_id, organization_id): + captured["service_user_id"] = user_id + captured["service_organization_id"] = organization_id + + def list_items_management_response(self, requested_collection_id): + captured["projection_collection_id"] = requested_collection_id + return KnowledgeCollectionItemsResponse( + items=[], + order_revision=revision, + ) + + monkeypatch.setattr( + knowledge_endpoint, + "build_knowledge_collection_lifecycle_and_order_use_case", + lambda db: FakeUseCase(), + ) + monkeypatch.setattr(knowledge_endpoint, "KnowledgeCollectionService", FakeService) + app.dependency_overrides[knowledge_endpoint.get_db] = lambda: object() + app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(id=user_id) + try: + response = TestClient(app).patch( + f"/api/v1/knowledge/collections/{collection_id}/items/reorder", + json={ + "items": [], + "expected_order_revision": revision, + }, + headers={"X-Organization-Id": str(organization_id)}, + ) + finally: + app.dependency_overrides = {} + + assert response.status_code == 200 + assert captured["command"].items == () + assert captured["projection_collection_id"] == collection_id + assert response.json()["items"] == [] diff --git a/apps/gateway/tests/application/knowledge_administration/__init__.py b/apps/gateway/tests/application/knowledge_administration/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apps/gateway/tests/application/knowledge_administration/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/gateway/tests/application/knowledge_administration/test_collection_operations.py b/apps/gateway/tests/application/knowledge_administration/test_collection_operations.py new file mode 100644 index 000000000..0d561cda3 --- /dev/null +++ b/apps/gateway/tests/application/knowledge_administration/test_collection_operations.py @@ -0,0 +1,419 @@ +import uuid +from datetime import datetime, timezone + +import pytest + +from apps.gateway.application.knowledge_administration.collection_operations import ( + CollectionHidden, + CollectionInputInvalid, + CollectionItemOrderSnapshot, + CollectionItemRank, + CollectionOperationCommand, + CollectionPermissionDenied, + CollectionPolicyBlocked, + CollectionPolicyDenied, + CollectionSnapshot, + CollectionStateConflict, + CollectionLifecycleAndOrderUseCase, + ReorderCollectionItemsCommand, + compute_order_revision, + validate_exact_reorder, +) + + +class _Authorization: + def __init__(self, *, manager=False, domain=(), collection_actions=()): + self.manager = manager + self.domain = set(domain) + self.collection_actions = set(collection_actions) + + def is_organization_manager(self, actor_id, organization_id): + return self.manager + + def has_domain_action(self, actor_id, organization_id, action): + return action in self.domain + + def has_collection_action( + self, actor_id, organization_id, collection_id, action + ): + return action in self.collection_actions + + +class _Repository: + def __init__(self, collection, items=(), *, has_source_managed_items=False): + self.collection = collection + self.items = list(items) + self.source_managed_items = has_source_managed_items + self.lifecycle_changes = [] + self.rank_changes = [] + self.locked_collection_ids = [] + self.locked_item_order_ids = [] + + def lock_collection(self, organization_id, collection_id): + self.locked_collection_ids.append(collection_id) + if self.collection is None or self.collection.collection_id != collection_id: + return None + return self.collection + + def set_lifecycle_state(self, organization_id, collection_id, lifecycle_state): + self.lifecycle_changes.append(lifecycle_state) + + def lock_item_order(self, organization_id, collection_id): + self.locked_item_order_ids.append(collection_id) + return list(self.items) + + def set_item_ranks(self, organization_id, collection_id, ranks): + self.rank_changes.append(tuple(ranks)) + + def has_source_managed_items(self, organization_id, collection_id): + return self.source_managed_items + + +class _Audit: + def __init__(self): + self.records = [] + + def record(self, **kwargs): + self.records.append(kwargs) + + +class _UnitOfWork: + def __init__(self): + self.flush_count = 0 + self.commit_count = 0 + self.rollback_count = 0 + + def flush(self): + self.flush_count += 1 + + def commit(self): + self.commit_count += 1 + + def rollback(self): + self.rollback_count += 1 + + +def _collection(**updates): + values = { + "collection_id": uuid.uuid4(), + "lifecycle_state": "active", + "sync_state": "manual", + "is_system_managed": False, + "is_source_managed": False, + "visibility": "private", + } + values.update(updates) + return CollectionSnapshot(**values) + + +def _item(rank): + return CollectionItemOrderSnapshot( + item_id=uuid.uuid4(), + rank=rank, + created_at=datetime(2026, 1, rank + 1, tzinfo=timezone.utc), + ) + + +def _use_case( + collection, + *, + authorization=None, + items=(), + has_source_managed_items=False, +): + repository = _Repository( + collection, + items, + has_source_managed_items=has_source_managed_items, + ) + audit = _Audit() + unit_of_work = _UnitOfWork() + use_case = CollectionLifecycleAndOrderUseCase( + authorization or _Authorization(manager=True), + repository, + audit, + unit_of_work, + ) + return use_case, repository, audit, unit_of_work + + +def _command(collection): + return CollectionOperationCommand( + actor_id=uuid.uuid4(), + organization_id=uuid.uuid4(), + collection_id=collection.collection_id, + ) + + +def test_restore_archived_manual_collection_commits_state_and_audit_once(): + collection = _collection(lifecycle_state="archived") + use_case, repository, audit, unit_of_work = _use_case(collection) + + result = use_case.restore(_command(collection)) + + assert result.status == "changed" + assert repository.lifecycle_changes == ["active"] + assert [record["action"] for record in audit.records] == [ + "knowledge.collection.restored" + ] + assert unit_of_work.flush_count == 1 + assert unit_of_work.commit_count == 1 + assert unit_of_work.rollback_count == 0 + + +def test_restore_active_is_idempotent_without_audit(): + collection = _collection() + use_case, repository, audit, unit_of_work = _use_case(collection) + + result = use_case.restore(_command(collection)) + + assert result.status == "unchanged" + assert repository.lifecycle_changes == [] + assert audit.records == [] + assert unit_of_work.commit_count == 0 + assert unit_of_work.rollback_count == 1 + + +@pytest.mark.parametrize( + ("updates", "error_type"), + [ + ({"lifecycle_state": "deleted"}, CollectionHidden), + ({"is_system_managed": True}, CollectionPolicyDenied), + ( + {"lifecycle_state": "archived", "sync_state": "source_deleted"}, + CollectionStateConflict, + ), + ], +) +def test_restore_rejects_ineligible_collection_without_mutation(updates, error_type): + collection = _collection(**({"lifecycle_state": "archived"} | updates)) + use_case, repository, audit, unit_of_work = _use_case(collection) + + with pytest.raises(error_type): + use_case.restore(_command(collection)) + + assert repository.lifecycle_changes == [] + assert audit.records == [] + assert unit_of_work.rollback_count == 1 + + +def test_restore_requires_lifecycle_or_collection_authority(): + collection = _collection(lifecycle_state="archived") + use_case, repository, audit, unit_of_work = _use_case( + collection, authorization=_Authorization() + ) + + with pytest.raises(CollectionPermissionDenied): + use_case.restore(_command(collection)) + + assert repository.lifecycle_changes == [] + assert audit.records == [] + assert unit_of_work.rollback_count == 1 + + +def test_order_revision_changes_for_membership_or_rank(): + collection_id = uuid.uuid4() + first = _item(0) + second = _item(1) + + baseline = compute_order_revision(collection_id, [first, second]) + reordered = compute_order_revision( + collection_id, + [ + CollectionItemOrderSnapshot(first.item_id, 1, first.created_at), + CollectionItemOrderSnapshot(second.item_id, 0, second.created_at), + ], + ) + removed = compute_order_revision(collection_id, [first]) + + assert baseline.startswith("ord_v1_") + assert len(baseline) == len("ord_v1_") + 64 + assert len({baseline, reordered, removed}) == 3 + + +def test_empty_order_has_stable_revision_and_reorder_cap_is_fail_closed(): + collection_id = uuid.uuid4() + + assert compute_order_revision(collection_id, []) == compute_order_revision( + collection_id, [] + ) + oversized = [ + CollectionItemOrderSnapshot( + item_id=uuid.uuid4(), + rank=rank, + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + for rank in range(501) + ] + with pytest.raises(CollectionStateConflict) as exc_info: + validate_exact_reorder( + oversized, + [CollectionItemRank(item.item_id, item.rank) for item in oversized], + ) + + assert exc_info.value.reason_code == "item_reorder_limit_exceeded" + + +def test_reorder_requires_current_revision_and_exact_contiguous_item_set(): + collection = _collection() + items = [_item(0), _item(1), _item(2)] + use_case, repository, audit, unit_of_work = _use_case(collection, items=items) + current_revision = compute_order_revision(collection.collection_id, items) + command = ReorderCollectionItemsCommand( + **_command(collection).__dict__, + expected_order_revision=current_revision, + items=( + CollectionItemRank(items[2].item_id, 0), + CollectionItemRank(items[0].item_id, 1), + CollectionItemRank(items[1].item_id, 2), + ), + ) + + result = use_case.reorder(command) + + assert result.status == "changed" + assert result.order_revision != current_revision + assert len(repository.rank_changes) == 1 + assert [record["action"] for record in audit.records] == [ + "knowledge.collection.items.reordered" + ] + assert unit_of_work.commit_count == 1 + + +def test_reorder_rejects_stale_revision_without_rank_change(): + collection = _collection() + items = [_item(0), _item(1)] + use_case, repository, audit, unit_of_work = _use_case(collection, items=items) + command = ReorderCollectionItemsCommand( + **_command(collection).__dict__, + expected_order_revision="ord_v1_" + "0" * 64, + items=tuple(CollectionItemRank(item.item_id, item.rank) for item in items), + ) + + with pytest.raises(CollectionStateConflict) as exc_info: + use_case.reorder(command) + + assert exc_info.value.reason_code == "collection_order_stale" + assert repository.rank_changes == [] + assert audit.records == [] + assert unit_of_work.rollback_count == 1 + + +def test_reorder_hides_deleted_collection_before_item_order_access(): + collection = _collection(lifecycle_state="deleted") + use_case, repository, audit, unit_of_work = _use_case(collection) + command = ReorderCollectionItemsCommand( + **_command(collection).__dict__, + expected_order_revision="unreachable", + items=(), + ) + + with pytest.raises(CollectionHidden): + use_case.reorder(command) + + assert repository.locked_item_order_ids == [] + assert repository.rank_changes == [] + assert audit.records == [] + assert unit_of_work.commit_count == 0 + assert unit_of_work.rollback_count == 1 + + +@pytest.mark.parametrize( + "ranks", + [ + (0, 0), + (0, 2), + (1, 2), + (-1, 0), + ], +) +def test_reorder_rejects_duplicate_or_gapped_ranks(ranks): + collection = _collection() + items = [_item(0), _item(1)] + use_case, repository, audit, unit_of_work = _use_case(collection, items=items) + command = ReorderCollectionItemsCommand( + **_command(collection).__dict__, + expected_order_revision=compute_order_revision(collection.collection_id, items), + items=tuple( + CollectionItemRank(item.item_id, rank) + for item, rank in zip(items, ranks, strict=True) + ), + ) + + with pytest.raises(CollectionInputInvalid): + use_case.reorder(command) + + assert repository.rank_changes == [] + assert audit.records == [] + assert unit_of_work.rollback_count == 1 + + +def test_public_reorder_requires_organization_manager_acknowledgement(): + collection = _collection(visibility="public") + items = [_item(0)] + use_case, _, _, unit_of_work = _use_case(collection, items=items) + command = ReorderCollectionItemsCommand( + **_command(collection).__dict__, + expected_order_revision=compute_order_revision(collection.collection_id, items), + items=(CollectionItemRank(items[0].item_id, 0),), + acknowledged_public_runtime_exposure=False, + ) + + with pytest.raises(CollectionInputInvalid): + use_case.reorder(command) + + assert unit_of_work.rollback_count == 1 + + +@pytest.mark.parametrize( + "collection_updates,has_source_managed_items", + [ + ({"is_source_managed": True}, False), + ({}, True), + ], +) +def test_public_reorder_blocks_source_managed_exposure_without_public_primitive( + collection_updates, + has_source_managed_items, +): + collection = _collection(visibility="public", **collection_updates) + items = [_item(0)] + use_case, repository, audit, unit_of_work = _use_case( + collection, + items=items, + has_source_managed_items=has_source_managed_items, + ) + command = ReorderCollectionItemsCommand( + **_command(collection).__dict__, + expected_order_revision=compute_order_revision(collection.collection_id, items), + items=(CollectionItemRank(items[0].item_id, 0),), + acknowledged_public_runtime_exposure=True, + ) + + with pytest.raises(CollectionPolicyBlocked) as exc_info: + use_case.reorder(command) + + assert exc_info.value.reason_code == "source_public_exposure_required" + assert repository.rank_changes == [] + assert audit.records == [] + assert unit_of_work.rollback_count == 1 + + +def test_noop_reorder_rolls_back_lock_without_audit(): + collection = _collection() + items = [_item(0), _item(1)] + use_case, repository, audit, unit_of_work = _use_case(collection, items=items) + revision = compute_order_revision(collection.collection_id, items) + command = ReorderCollectionItemsCommand( + **_command(collection).__dict__, + expected_order_revision=revision, + items=tuple(CollectionItemRank(item.item_id, item.rank) for item in items), + ) + + result = use_case.reorder(command) + + assert result.status == "unchanged" + assert result.order_revision == revision + assert repository.rank_changes == [] + assert audit.records == [] + assert unit_of_work.rollback_count == 1 diff --git a/apps/gateway/tests/application/knowledge_administration/test_delegation_subjects.py b/apps/gateway/tests/application/knowledge_administration/test_delegation_subjects.py new file mode 100644 index 000000000..195e50244 --- /dev/null +++ b/apps/gateway/tests/application/knowledge_administration/test_delegation_subjects.py @@ -0,0 +1,70 @@ +import uuid + +import pytest + +from apps.gateway.application.knowledge_administration.delegation_subjects import ( + DelegationSubjectPageInvalid, + decode_subject_cursor, + encode_subject_cursor, + escape_like_prefix, + normalize_subject_query, + normalize_subject_type, + validate_subject_page_size, +) + + +def test_subject_query_normalizes_whitespace_without_interpreting_wildcards(): + assert normalize_subject_query(" 지식\t Team ") == "지식 Team" + assert escape_like_prefix(r"A%_\B") == r"A\%\_\\B" + + +@pytest.mark.parametrize("value", ["x" * 101, "name\x00value"]) +def test_subject_query_rejects_oversized_or_control_text(value): + with pytest.raises(DelegationSubjectPageInvalid): + normalize_subject_query(value) + + +def test_subject_cursor_is_bound_to_type_and_normalized_query(): + subject_id = uuid.uuid4() + cursor = encode_subject_cursor( + subject_type="team", + query="지식 Team", + last_subject_id=subject_id, + ) + + decoded = decode_subject_cursor( + cursor, + subject_type="team", + query="지식 Team", + ) + + assert decoded is not None + assert decoded.last_subject_id == subject_id + assert str(subject_id) not in cursor + with pytest.raises(DelegationSubjectPageInvalid): + decode_subject_cursor(cursor, subject_type="user", query="지식 Team") + with pytest.raises(DelegationSubjectPageInvalid): + decode_subject_cursor(cursor, subject_type="team", query="다른 검색") + + +@pytest.mark.parametrize("cursor", ["", "not-base64!", "a" * 129]) +def test_subject_cursor_rejects_malformed_or_oversized_values(cursor): + with pytest.raises(DelegationSubjectPageInvalid): + decode_subject_cursor(cursor, subject_type="team", query="") + + +@pytest.mark.parametrize("limit", [0, 51]) +def test_subject_page_size_is_bounded(limit): + with pytest.raises(DelegationSubjectPageInvalid): + validate_subject_page_size(limit) + + +def test_subject_page_size_accepts_boundaries(): + assert validate_subject_page_size(1) == 1 + assert validate_subject_page_size("50") == 50 + + +def test_subject_type_rejects_values_outside_the_closed_union(): + assert normalize_subject_type("team") == "team" + with pytest.raises(DelegationSubjectPageInvalid): + normalize_subject_type("organization") diff --git a/apps/gateway/tests/integration/test_knowledge_collection_operations_postgres.py b/apps/gateway/tests/integration/test_knowledge_collection_operations_postgres.py new file mode 100644 index 000000000..9ed13418f --- /dev/null +++ b/apps/gateway/tests/integration/test_knowledge_collection_operations_postgres.py @@ -0,0 +1,967 @@ +import os +import subprocess +import sys +import threading +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, event, func, select, text +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import sessionmaker + +from apps.gateway.adapters.audit.knowledge_collection_operations import ( + SqlAlchemyKnowledgeCollectionOperationAudit, +) +from apps.gateway.adapters.db.knowledge_collection_operations import ( + SqlAlchemyKnowledgeCollectionOperationRepository, +) +from apps.gateway.adapters.db.sqlalchemy_unit_of_work import SqlAlchemyUnitOfWork +from apps.gateway.application.knowledge_administration.collection_operations import ( + CollectionItemRank, + CollectionLifecycleAndOrderUseCase, + CollectionOperationCommand, + CollectionPersistenceFailed, + CollectionStateConflict, + ReorderCollectionItemsCommand, + compute_order_revision, +) +from apps.gateway.services.knowledge_collection_service import ( + KnowledgeCollectionService, + KnowledgeCollectionServiceError, +) +from apps.shared.db.models.audit_log import AuditLog +from apps.shared.db.models.knowledge import ( + KnowledgeBase, + KnowledgeCollection, + KnowledgeCollectionItem, +) +from apps.shared.db.models.organization import Organization +from apps.shared.db.models.organization_membership import OrganizationMembership +from apps.shared.db.models.team import ( + Team, + UserKnowledgeCollectionPermission, +) +from apps.shared.db.models.user import User +from apps.shared.schemas.knowledge import ( + KnowledgeCollectionPermissionBulkBundleRequest, +) +from apps.shared.tests.helpers.disposable_postgres import ( + DisposablePostgresConfig, + DisposablePostgresConfigurationError, + quote_disposable_database_name, +) + + +ROOT_DIR = Path(__file__).resolve().parents[4] +RUN_ENV = "NODEASE_RUN_DISPOSABLE_DB_TEST" +DB_PREFIX = "mba264_collection_ops" + +pytestmark = pytest.mark.skipif( + os.getenv(RUN_ENV) != "1", + reason=f"set {RUN_ENV}=1 to run disposable PostgreSQL collection integration", +) + + +class _AllowAll: + def is_organization_manager(self, _actor_id, _organization_id): + return True + + def has_domain_action(self, _actor_id, _organization_id, _action): + return False + + def has_collection_action( + self, + _actor_id, + _organization_id, + _collection_id, + _action, + ): + return False + + +class _FailingAudit: + def record(self, **_kwargs): + raise RuntimeError("simulated audit failure") + + +class _HoldingAudit: + def __init__(self, db, lock_held, release_first): + self.delegate = SqlAlchemyKnowledgeCollectionOperationAudit(db) + self.lock_held = lock_held + self.release_first = release_first + + def record(self, **kwargs): + self.delegate.record(**kwargs) + self.lock_held.set() + if not self.release_first.wait(timeout=10): + raise RuntimeError("concurrent collection operation release timed out") + + +def _run_migrations(database, config): + completed = subprocess.run( + [ + sys.executable, + "-m", + "alembic", + "-c", + "apps/shared/alembic.ini", + "upgrade", + "heads", + ], + cwd=ROOT_DIR, + env=config.subprocess_environment(database=database, root_dir=ROOT_DIR), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=180, + check=False, + ) + if completed.returncode != 0: + raise pytest.fail.Exception( + "disposable PostgreSQL migration failed; output omitted", + pytrace=False, + ) + + +@pytest.fixture(scope="module") +def postgres(): + try: + config = DisposablePostgresConfig.from_environment() + except DisposablePostgresConfigurationError: + raise pytest.fail.Exception( + "disposable PostgreSQL connection settings are not safely configured", + pytrace=False, + ) from None + + database = f"{DB_PREFIX}_{uuid.uuid4().hex[:12]}" + quoted_database = quote_disposable_database_name(database, prefix=DB_PREFIX) + admin_engine = create_engine( + config.database_url(config.maintenance_database), + isolation_level="AUTOCOMMIT", + ) + database_created = False + engine = None + try: + with admin_engine.connect() as connection: + connection.execute(text(f"CREATE DATABASE {quoted_database}")) + database_created = True + + extension_engine = create_engine( + config.database_url(database), + isolation_level="AUTOCOMMIT", + ) + try: + with extension_engine.connect() as connection: + connection.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + finally: + extension_engine.dispose() + + _run_migrations(database, config) + engine = create_engine(config.database_url(database), pool_pre_ping=True) + yield engine, sessionmaker(bind=engine, expire_on_commit=False) + except OperationalError: + raise pytest.fail.Exception( + "disposable PostgreSQL is unavailable or rejected the connection; " + "connection details omitted", + pytrace=False, + ) from None + finally: + if engine is not None: + engine.dispose() + if database_created: + try: + with admin_engine.connect() as connection: + connection.execute( + text( + """ + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = :database + AND pid <> pg_backend_pid() + """ + ), + {"database": database}, + ) + connection.execute( + text(f"DROP DATABASE IF EXISTS {quoted_database}") + ) + except OperationalError: + raise pytest.fail.Exception( + "disposable PostgreSQL cleanup could not connect; " + "connection details omitted", + pytrace=False, + ) from None + admin_engine.dispose() + + +def _seed_users_and_organizations(db, prefix): + actor_id = uuid.uuid4() + subject_id = uuid.uuid4() + organization_a = uuid.uuid4() + organization_b = uuid.uuid4() + db.add_all( + [ + User( + id=actor_id, + email=f"{prefix}-actor-{actor_id}@example.invalid", + name=f"{prefix} actor", + social_provider="local", + ), + User( + id=subject_id, + email=f"{prefix}-subject-{subject_id}@example.invalid", + name=f"{prefix} subject", + social_provider="local", + ), + ] + ) + db.flush() + db.add_all( + [ + Organization( + id=organization_a, + name=f"{prefix} organization A {organization_a}", + created_by=actor_id, + ), + Organization( + id=organization_b, + name=f"{prefix} organization B {organization_b}", + created_by=actor_id, + ), + ] + ) + db.flush() + return actor_id, subject_id, organization_a, organization_b + + +def _use_case(db, *, audit=None): + return CollectionLifecycleAndOrderUseCase( + _AllowAll(), + SqlAlchemyKnowledgeCollectionOperationRepository(db), + audit or SqlAlchemyKnowledgeCollectionOperationAudit(db), + SqlAlchemyUnitOfWork(db), + ) + + +def _collection_command(actor_id, organization_id, collection_id): + return CollectionOperationCommand( + actor_id=actor_id, + organization_id=organization_id, + collection_id=collection_id, + ) + + +def _observe_second_collection_lock(engine, thread_name, started): + def observer( + _connection, + _cursor, + statement, + _parameters, + _context, + _executemany, + ): + normalized = statement.lower() + if ( + threading.current_thread().name == thread_name + and "knowledge_collections" in normalized + and "for update" in normalized + ): + started.set() + + event.listen(engine, "before_cursor_execute", observer) + return observer + + +def test_collection_restore_is_scoped_atomic_and_serialized(postgres): + engine, session_factory = postgres + with session_factory() as db: + actor_id, _, organization_a, organization_b = _seed_users_and_organizations( + db, "mba264-restore" + ) + rollback_collection_id = uuid.uuid4() + concurrent_collection_id = uuid.uuid4() + db.add_all( + [ + KnowledgeCollection( + id=rollback_collection_id, + organization_id=organization_a, + name="MBA-264 restore rollback", + created_by=actor_id, + lifecycle_state="archived", + ), + KnowledgeCollection( + id=concurrent_collection_id, + organization_id=organization_a, + name="MBA-264 restore concurrent", + created_by=actor_id, + lifecycle_state="archived", + ), + ] + ) + db.commit() + + with session_factory() as db: + repository = SqlAlchemyKnowledgeCollectionOperationRepository(db) + assert repository.lock_collection(organization_b, rollback_collection_id) is None + db.rollback() + + with session_factory() as db: + with pytest.raises(CollectionPersistenceFailed): + _use_case(db, audit=_FailingAudit()).restore( + _collection_command( + actor_id, + organization_a, + rollback_collection_id, + ) + ) + + with session_factory() as db: + assert ( + db.get(KnowledgeCollection, rollback_collection_id).lifecycle_state + == "archived" + ) + assert ( + db.scalar( + select(func.count()) + .select_from(AuditLog) + .where( + AuditLog.action == "knowledge.collection.restored", + AuditLog.target_id == str(rollback_collection_id), + ) + ) + == 0 + ) + + first_lock_held = threading.Event() + release_first = threading.Event() + second_select_started = threading.Event() + results = [] + errors = [] + result_lock = threading.Lock() + command = _collection_command( + actor_id, + organization_a, + concurrent_collection_id, + ) + observer = _observe_second_collection_lock( + engine, + "mba264-restore-second", + second_select_started, + ) + + def restore_in_thread(*, hold_first): + db = session_factory() + try: + audit = ( + _HoldingAudit(db, first_lock_held, release_first) + if hold_first + else SqlAlchemyKnowledgeCollectionOperationAudit(db) + ) + result = _use_case(db, audit=audit).restore(command) + with result_lock: + results.append(result.status) + except Exception as exc: + with result_lock: + errors.append(type(exc).__name__) + finally: + db.close() + + first = threading.Thread( + target=restore_in_thread, + kwargs={"hold_first": True}, + name="mba264-restore-first", + ) + second = threading.Thread( + target=restore_in_thread, + kwargs={"hold_first": False}, + name="mba264-restore-second", + ) + try: + first.start() + assert first_lock_held.wait(timeout=10) + second.start() + assert second_select_started.wait(timeout=10) + assert second.is_alive() + release_first.set() + first.join(timeout=10) + second.join(timeout=10) + assert not first.is_alive() + assert not second.is_alive() + finally: + release_first.set() + for worker in (first, second): + if worker.is_alive(): + worker.join(timeout=10) + event.remove(engine, "before_cursor_execute", observer) + + assert errors == [] + assert sorted(results) == ["changed", "unchanged"] + with session_factory() as db: + assert ( + db.get(KnowledgeCollection, concurrent_collection_id).lifecycle_state + == "active" + ) + assert ( + db.scalar( + select(func.count()) + .select_from(AuditLog) + .where( + AuditLog.action == "knowledge.collection.restored", + AuditLog.target_id == str(concurrent_collection_id), + ) + ) + == 1 + ) + + +def test_collection_reorder_is_atomic_and_stale_safe(postgres): + engine, session_factory = postgres + now = datetime.now(timezone.utc) + with session_factory() as db: + actor_id, _, organization_a, _ = _seed_users_and_organizations( + db, "mba264-reorder" + ) + collection_id = uuid.uuid4() + kb_ids = [uuid.uuid4(), uuid.uuid4()] + item_ids = [uuid.uuid4(), uuid.uuid4()] + db.add( + KnowledgeCollection( + id=collection_id, + organization_id=organization_a, + name="MBA-264 reorder", + created_by=actor_id, + ) + ) + db.add_all( + [ + KnowledgeBase( + id=kb_id, + organization_id=organization_a, + user_id=actor_id, + name=f"MBA-264 reorder KB {index}", + ) + for index, kb_id in enumerate(kb_ids) + ] + ) + db.flush() + db.add_all( + [ + KnowledgeCollectionItem( + id=item_ids[index], + organization_id=organization_a, + collection_id=collection_id, + knowledge_base_id=kb_ids[index], + rank=index, + created_at=now + timedelta(seconds=index), + ) + for index in range(2) + ] + ) + db.commit() + + with session_factory() as db: + repository = SqlAlchemyKnowledgeCollectionOperationRepository(db) + assert repository.lock_collection(organization_a, collection_id) is not None + current = repository.lock_item_order(organization_a, collection_id) + revision = compute_order_revision(collection_id, current) + db.rollback() + + command = ReorderCollectionItemsCommand( + actor_id=actor_id, + organization_id=organization_a, + collection_id=collection_id, + expected_order_revision=revision, + items=( + CollectionItemRank(item_ids[1], 0), + CollectionItemRank(item_ids[0], 1), + ), + ) + with session_factory() as db: + with pytest.raises(CollectionPersistenceFailed): + _use_case(db, audit=_FailingAudit()).reorder(command) + + with session_factory() as db: + rows = db.scalars( + select(KnowledgeCollectionItem) + .where(KnowledgeCollectionItem.collection_id == collection_id) + .order_by(KnowledgeCollectionItem.rank.asc()) + ).all() + assert [(row.id, row.rank) for row in rows] == [ + (item_ids[0], 0), + (item_ids[1], 1), + ] + assert ( + db.scalar( + select(func.count()) + .select_from(AuditLog) + .where( + AuditLog.action == "knowledge.collection.items.reordered", + AuditLog.target_id == str(collection_id), + ) + ) + == 0 + ) + + first_lock_held = threading.Event() + release_first = threading.Event() + second_select_started = threading.Event() + results = [] + result_lock = threading.Lock() + observer = _observe_second_collection_lock( + engine, + "mba264-reorder-second", + second_select_started, + ) + + def reorder_in_thread(*, hold_first): + db = session_factory() + try: + audit = ( + _HoldingAudit(db, first_lock_held, release_first) + if hold_first + else SqlAlchemyKnowledgeCollectionOperationAudit(db) + ) + result = _use_case(db, audit=audit).reorder(command) + outcome = result.status + except CollectionStateConflict as exc: + outcome = exc.reason_code + except Exception as exc: + outcome = type(exc).__name__ + finally: + db.close() + with result_lock: + results.append(outcome) + + first = threading.Thread( + target=reorder_in_thread, + kwargs={"hold_first": True}, + name="mba264-reorder-first", + ) + second = threading.Thread( + target=reorder_in_thread, + kwargs={"hold_first": False}, + name="mba264-reorder-second", + ) + try: + first.start() + assert first_lock_held.wait(timeout=10) + second.start() + assert second_select_started.wait(timeout=10) + assert second.is_alive() + release_first.set() + first.join(timeout=10) + second.join(timeout=10) + assert not first.is_alive() + assert not second.is_alive() + finally: + release_first.set() + for worker in (first, second): + if worker.is_alive(): + worker.join(timeout=10) + event.remove(engine, "before_cursor_execute", observer) + + assert sorted(results) == ["changed", "collection_order_stale"] + with session_factory() as db: + rows = db.scalars( + select(KnowledgeCollectionItem) + .where(KnowledgeCollectionItem.collection_id == collection_id) + .order_by(KnowledgeCollectionItem.rank.asc()) + ).all() + assert [(row.id, row.rank) for row in rows] == [ + (item_ids[1], 0), + (item_ids[0], 1), + ] + assert ( + db.scalar( + select(func.count()) + .select_from(AuditLog) + .where( + AuditLog.action == "knowledge.collection.items.reordered", + AuditLog.target_id == str(collection_id), + ) + ) + == 1 + ) + + +def test_collection_bulk_bundle_is_all_or_nothing_and_deadlock_safe(postgres): + engine, session_factory = postgres + with session_factory() as db: + actor_id, subject_id, organization_a, organization_b = ( + _seed_users_and_organizations(db, "mba264-bulk") + ) + collection_a_ids = [uuid.uuid4(), uuid.uuid4()] + collection_b_id = uuid.uuid4() + db.add_all( + [ + OrganizationMembership( + organization_id=organization_a, + user_id=actor_id, + membership_state="active", + organization_auth_state="member", + ), + OrganizationMembership( + organization_id=organization_a, + user_id=subject_id, + membership_state="active", + organization_auth_state="member", + ), + KnowledgeCollection( + id=collection_a_ids[0], + organization_id=organization_a, + name="MBA-264 bulk A1", + created_by=actor_id, + ), + KnowledgeCollection( + id=collection_a_ids[1], + organization_id=organization_a, + name="MBA-264 bulk A2", + created_by=actor_id, + ), + KnowledgeCollection( + id=collection_b_id, + organization_id=organization_b, + name="MBA-264 bulk B", + created_by=actor_id, + ), + ] + ) + db.flush() + actor_manage_permission_id = uuid.uuid4() + db.add( + UserKnowledgeCollectionPermission( + id=actor_manage_permission_id, + grantee_organization_id=organization_a, + user_id=actor_id, + assigned_by=actor_id, + knowledge_collection_id=collection_a_ids[0], + permission_action="manage", + ) + ) + db.commit() + + def service(db, *, manager=False): + instance = KnowledgeCollectionService( + db, + user_id=actor_id, + organization_id=organization_a, + ) + if manager: + instance._is_org_manager = lambda: True + return instance + + cross_org_request = KnowledgeCollectionPermissionBulkBundleRequest( + collection_ids=[collection_a_ids[0], collection_b_id], + operation="grant", + subject_type="user", + subject_id=subject_id, + role_bundle="viewer", + ) + with session_factory() as db: + with pytest.raises(KnowledgeCollectionServiceError) as exc_info: + service(db, manager=True).mutate_permission_bundle_bulk( + cross_org_request + ) + assert exc_info.value.status_code == 404 + + unauthorized_request = KnowledgeCollectionPermissionBulkBundleRequest( + collection_ids=collection_a_ids, + operation="grant", + subject_type="user", + subject_id=subject_id, + role_bundle="viewer", + ) + with session_factory() as db: + with pytest.raises(KnowledgeCollectionServiceError) as exc_info: + service(db).mutate_permission_bundle_bulk(unauthorized_request) + assert exc_info.value.status_code == 403 + + self_revoke_request = KnowledgeCollectionPermissionBulkBundleRequest( + collection_ids=[collection_a_ids[0]], + operation="revoke", + subject_type="user", + subject_id=actor_id, + role_bundle="maintainer", + ) + with session_factory() as db: + with pytest.raises(KnowledgeCollectionServiceError) as exc_info: + service(db).mutate_permission_bundle_bulk(self_revoke_request) + assert exc_info.value.status_code == 403 + + rollback_request = KnowledgeCollectionPermissionBulkBundleRequest( + collection_ids=[collection_a_ids[0]], + operation="grant", + subject_type="user", + subject_id=subject_id, + role_bundle="viewer", + ) + with session_factory() as db: + instance = service(db, manager=True) + instance._record_collection_audit = lambda *_args, **_kwargs: (_ for _ in ()).throw( + RuntimeError("simulated audit failure") + ) + with pytest.raises(RuntimeError, match="^simulated audit failure$"): + instance.mutate_permission_bundle_bulk(rollback_request) + + with session_factory() as db: + assert db.get(UserKnowledgeCollectionPermission, actor_manage_permission_id) + assert ( + db.scalar( + select(func.count()) + .select_from(UserKnowledgeCollectionPermission) + .where( + UserKnowledgeCollectionPermission.user_id == subject_id, + UserKnowledgeCollectionPermission.grantee_organization_id + == organization_a, + ) + ) + == 0 + ) + + first_lock_held = threading.Event() + release_first = threading.Event() + second_select_started = threading.Event() + results = [] + errors = [] + result_lock = threading.Lock() + observer = _observe_second_collection_lock( + engine, + "mba264-bulk-second", + second_select_started, + ) + + def bulk_in_thread(collection_ids, *, hold_first): + db = session_factory() + try: + instance = service(db, manager=True) + if hold_first: + original_record = instance._record_collection_audit + held = False + + def holding_record(*args, **kwargs): + nonlocal held + original_record(*args, **kwargs) + if not held: + held = True + first_lock_held.set() + if not release_first.wait(timeout=10): + raise RuntimeError("concurrent bulk release timed out") + + instance._record_collection_audit = holding_record + response = instance.mutate_permission_bundle_bulk( + KnowledgeCollectionPermissionBulkBundleRequest( + collection_ids=collection_ids, + operation="grant", + subject_type="user", + subject_id=subject_id, + role_bundle="workflow_router", + ) + ) + with result_lock: + results.append( + (response.changed_count_bucket, response.unchanged_count_bucket) + ) + except Exception as exc: + with result_lock: + errors.append(type(exc).__name__) + finally: + db.close() + + first = threading.Thread( + target=bulk_in_thread, + args=(list(reversed(collection_a_ids)),), + kwargs={"hold_first": True}, + name="mba264-bulk-first", + ) + second = threading.Thread( + target=bulk_in_thread, + args=(collection_a_ids,), + kwargs={"hold_first": False}, + name="mba264-bulk-second", + ) + try: + first.start() + assert first_lock_held.wait(timeout=10) + second.start() + assert second_select_started.wait(timeout=10) + assert second.is_alive() + release_first.set() + first.join(timeout=10) + second.join(timeout=10) + assert not first.is_alive() + assert not second.is_alive() + finally: + release_first.set() + for worker in (first, second): + if worker.is_alive(): + worker.join(timeout=10) + event.remove(engine, "before_cursor_execute", observer) + + assert errors == [] + assert sorted(results) == [("0", "2-10"), ("2-10", "0")] + with session_factory() as db: + permission_rows = db.scalars( + select(UserKnowledgeCollectionPermission).where( + UserKnowledgeCollectionPermission.user_id == subject_id, + UserKnowledgeCollectionPermission.grantee_organization_id + == organization_a, + ) + ).all() + assert { + (row.knowledge_collection_id, row.permission_action) + for row in permission_rows + } == { + (collection_id, action) + for collection_id in collection_a_ids + for action in ("read", "route") + } + assert ( + db.scalar( + select(func.count()) + .select_from(AuditLog) + .where( + AuditLog.action + == "knowledge.collection.permission_bundle.granted", + AuditLog.target_id.in_( + [str(collection_id) for collection_id in collection_a_ids] + ), + ) + ) + == 2 + ) + + +def test_delegation_subject_page_is_bounded_and_organization_scoped(postgres): + _, session_factory = postgres + with session_factory() as db: + actor_id, subject_id, organization_a, organization_b = ( + _seed_users_and_organizations(db, "mba264-subjects") + ) + db.get(User, subject_id).name = "Alpha first user" + second_subject_id = uuid.uuid4() + removed_subject_id = uuid.uuid4() + cross_subject_id = uuid.uuid4() + db.add_all( + [ + User( + id=second_subject_id, + email=f"mba264-second-{second_subject_id}@example.invalid", + name="Alpha second user", + social_provider="local", + ), + User( + id=removed_subject_id, + email=f"mba264-removed-{removed_subject_id}@example.invalid", + name="Alpha removed user", + social_provider="local", + ), + User( + id=cross_subject_id, + email=f"mba264-cross-{cross_subject_id}@example.invalid", + name="Alpha cross user", + social_provider="local", + ), + ] + ) + db.flush() + db.add_all( + [ + OrganizationMembership( + organization_id=organization_a, + user_id=actor_id, + membership_state="active", + organization_auth_state="manager", + ), + OrganizationMembership( + organization_id=organization_a, + user_id=subject_id, + membership_state="active", + organization_auth_state="member", + ), + OrganizationMembership( + organization_id=organization_a, + user_id=second_subject_id, + membership_state="active", + organization_auth_state="member", + ), + OrganizationMembership( + organization_id=organization_a, + user_id=removed_subject_id, + membership_state="removed", + organization_auth_state="member", + ), + OrganizationMembership( + organization_id=organization_b, + user_id=cross_subject_id, + membership_state="active", + organization_auth_state="member", + ), + Team( + organization_id=organization_a, + name="A% literal team", + created_by=actor_id, + is_active=True, + ), + Team( + organization_id=organization_a, + name="Ax wildcard decoy", + created_by=actor_id, + is_active=True, + ), + Team( + organization_id=organization_a, + name="A% inactive team", + created_by=actor_id, + is_active=False, + ), + Team( + organization_id=organization_b, + name="A% cross team", + created_by=actor_id, + is_active=True, + ), + ] + ) + db.commit() + + with session_factory() as db: + service = KnowledgeCollectionService( + db, + user_id=actor_id, + organization_id=organization_a, + ) + team_page = service.list_domain_delegation_subjects( + subject_type="team", + query="A%", + limit=1, + ) + assert [row.subject_safe_label for row in team_page.subjects] == [ + "A% literal team" + ] + assert team_page.next_cursor is None + + first_user_page = service.list_domain_delegation_subjects( + subject_type="user", + query="Alpha", + limit=1, + ) + assert len(first_user_page.subjects) == 1 + assert first_user_page.next_cursor is not None + second_user_page = service.list_domain_delegation_subjects( + subject_type="user", + query="Alpha", + cursor=first_user_page.next_cursor, + limit=1, + ) + user_ids = { + first_user_page.subjects[0].subject_id, + second_user_page.subjects[0].subject_id, + } + assert user_ids == {subject_id, second_subject_id} + assert second_user_page.next_cursor is None + assert removed_subject_id not in user_ids + assert cross_subject_id not in user_ids + assert "@" not in str(first_user_page.model_dump()) diff --git a/apps/gateway/tests/integration/test_knowledge_ingestion_status_sync_db.py b/apps/gateway/tests/integration/test_knowledge_ingestion_status_sync_db.py index 4da5c4901..c2cfe6262 100644 --- a/apps/gateway/tests/integration/test_knowledge_ingestion_status_sync_db.py +++ b/apps/gateway/tests/integration/test_knowledge_ingestion_status_sync_db.py @@ -3,6 +3,7 @@ import pytest from fastapi.testclient import TestClient +from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session from apps.gateway.api.v1.endpoints import knowledge as knowledge_endpoint @@ -23,7 +24,10 @@ @pytest.fixture def db_session(): - connection = engine.connect() + try: + connection = engine.connect() + except OperationalError: + pytest.skip("PostgreSQL integration database is unavailable") transaction = connection.begin() db = Session(bind=connection, join_transaction_mode="create_savepoint") try: diff --git a/apps/gateway/tests/integration/test_knowledge_safe_metadata_permission_db.py b/apps/gateway/tests/integration/test_knowledge_safe_metadata_permission_db.py index d16e65f8b..3fecfe0c8 100644 --- a/apps/gateway/tests/integration/test_knowledge_safe_metadata_permission_db.py +++ b/apps/gateway/tests/integration/test_knowledge_safe_metadata_permission_db.py @@ -2,6 +2,7 @@ import pytest from fastapi.testclient import TestClient +from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session from apps.gateway.api.v1.endpoints import knowledge as knowledge_endpoint @@ -17,7 +18,10 @@ @pytest.fixture def db_session(): - connection = engine.connect() + try: + connection = engine.connect() + except OperationalError: + pytest.skip("PostgreSQL integration database is unavailable") transaction = connection.begin() db = Session(bind=connection, join_transaction_mode="create_savepoint") try: diff --git a/apps/gateway/tests/services/test_knowledge_collection_service.py b/apps/gateway/tests/services/test_knowledge_collection_service.py index 0f95abde5..fb1ee4377 100644 --- a/apps/gateway/tests/services/test_knowledge_collection_service.py +++ b/apps/gateway/tests/services/test_knowledge_collection_service.py @@ -4,6 +4,7 @@ import pytest +import apps.gateway.services.knowledge_collection_service as knowledge_collection_service_module from apps.gateway.services.knowledge_collection_service import ( KnowledgeCollectionService, KnowledgeCollectionServiceError, @@ -14,6 +15,7 @@ KnowledgeCollectionCreateRequest, KnowledgeCollectionItemLinkRequest, KnowledgeCollectionPermissionBundleGrantRequest, + KnowledgeCollectionPermissionBulkBundleRequest, KnowledgeCollectionResponse, KnowledgeCollectionUpdateRequest, KnowledgeCollectionVisibilityRequest, @@ -39,6 +41,37 @@ def refresh(self, value): self.refreshed.append(value) +class _BulkQuery: + def __init__(self, rows): + self.rows = rows + + def filter(self, *_args, **_kwargs): + return self + + def order_by(self, *_args, **_kwargs): + return self + + def with_for_update(self): + return self + + def all(self): + return list(self.rows) + + +class _BulkDb(_FakeDb): + def __init__(self, collections): + super().__init__() + self.collections = collections + self.rollback_count = 0 + + def query(self, model): + return _BulkQuery(self.collections if model is KnowledgeCollection else []) + + def rollback(self): + self.rollback_count += 1 + self.operations.append(("rollback", None)) + + class _SubjectQuery: def __init__(self, rows): self.rows = rows @@ -52,6 +85,10 @@ def filter(self, *_args, **_kwargs): def order_by(self, *_args, **_kwargs): return self + def limit(self, value): + self.rows = self.rows[:value] + return self + def all(self): return self.rows @@ -96,6 +133,7 @@ def _collection(collection_id=None): description=None, is_system_managed=False, source_identity_id=None, + source_connector_ref=None, sync_state="manual", lifecycle_state="active", safe_metadata={}, @@ -287,6 +325,30 @@ def test_public_membership_requires_org_manager_ack_and_blocks_source_managed_li } +def test_public_membership_blocks_source_managed_collection_even_for_manual_kb( + monkeypatch, +): + service = _service(monkeypatch) + collection = _collection() + collection.safe_metadata = {"visibility": "public"} + collection.source_connector_ref = "opaque-connector-ref" + manual_kb = SimpleNamespace(id=uuid.uuid4(), source_identity_id=None) + monkeypatch.setattr(service, "_require_org_manager", lambda: None) + + with pytest.raises(KnowledgeCollectionServiceError) as exc_info: + service._require_collection_membership_mutation( + collection, + kb=manual_kb, + acknowledged_public_runtime_exposure=True, + adds_public_exposure=True, + ) + + assert exc_info.value.status_code == 409 + assert exc_info.value.details == { + "policy_reason": "source_public_exposure_required" + } + + def test_collection_role_bundle_writes_explicit_actions_in_one_transaction(monkeypatch): db = _FakeDb() service = KnowledgeCollectionService( @@ -297,7 +359,7 @@ def test_collection_role_bundle_writes_explicit_actions_in_one_transaction(monke collection = _collection() actions = [] - monkeypatch.setattr(service, "_collection_or_hidden", lambda _id: collection) + monkeypatch.setattr(service, "_locked_collection_or_hidden", lambda _id: collection) monkeypatch.setattr( service, "_require_collection_permission_authority", @@ -356,11 +418,40 @@ def test_domain_delegation_subjects_return_safe_team_and_user_labels(monkeypatch ) monkeypatch.setattr(service, "_require_org_manager", lambda: None) - result = service.list_domain_delegation_subjects() + team_result = service.list_domain_delegation_subjects(subject_type="team") + user_result = service.list_domain_delegation_subjects(subject_type="user") + + assert team_result.subjects[0].subject_safe_label == "Knowledge Team" + assert user_result.subjects[0].subject_safe_label == "User" + assert "@" not in str(user_result.model_dump()) + + +def test_domain_subject_authority_is_checked_before_page_validation(monkeypatch): + service = _service(monkeypatch) + monkeypatch.setattr( + service, + "_require_org_manager", + lambda: (_ for _ in ()).throw( + KnowledgeCollectionServiceError( + 403, + "permission.denied", + "Organization manager permission is required.", + ) + ), + ) + monkeypatch.setattr( + service, + "_delegation_subjects_response", + lambda **kwargs: pytest.fail("page validation must follow authority"), + ) + + with pytest.raises(KnowledgeCollectionServiceError) as exc_info: + service.list_domain_delegation_subjects( + subject_type="invalid", + limit="not-a-number", + ) - assert result.teams[0].subject_safe_label == "Knowledge Team" - assert result.users[0].subject_safe_label == "User" - assert "@" not in str(result.model_dump()) + assert exc_info.value.status_code == 403 def test_update_collection_rejects_blank_name_after_normalization(monkeypatch): @@ -435,7 +526,7 @@ def test_team_manage_revoke_allows_when_alternate_management_path_exists(monkeyp def test_public_visibility_requires_acknowledgement(monkeypatch): service = _service(monkeypatch) collection = _collection() - monkeypatch.setattr(service, "_collection_or_hidden", lambda collection_id: collection) + monkeypatch.setattr(service, "_locked_collection_or_hidden", lambda collection_id: collection) monkeypatch.setattr(service, "_require_org_manager", lambda: None) with pytest.raises(KnowledgeCollectionServiceError) as exc_info: @@ -456,7 +547,7 @@ def test_public_visibility_blocks_source_managed_items_without_approval_primitiv ): service = _service(monkeypatch) collection = _collection() - monkeypatch.setattr(service, "_collection_or_hidden", lambda collection_id: collection) + monkeypatch.setattr(service, "_locked_collection_or_hidden", lambda collection_id: collection) monkeypatch.setattr(service, "_require_org_manager", lambda: None) monkeypatch.setattr( service, @@ -480,13 +571,15 @@ def test_public_visibility_blocks_source_managed_items_without_approval_primitiv } +@pytest.mark.parametrize("source_field", ["source_identity_id", "source_connector_ref"]) def test_public_visibility_blocks_source_managed_collection_without_approval_primitive( monkeypatch, + source_field, ): service = _service(monkeypatch) collection = _collection() - collection.source_identity_id = uuid.uuid4() - monkeypatch.setattr(service, "_collection_or_hidden", lambda collection_id: collection) + setattr(collection, source_field, uuid.uuid4()) + monkeypatch.setattr(service, "_locked_collection_or_hidden", lambda collection_id: collection) monkeypatch.setattr(service, "_require_org_manager", lambda: None) with pytest.raises(KnowledgeCollectionServiceError) as exc_info: @@ -505,11 +598,137 @@ def test_public_visibility_blocks_source_managed_collection_without_approval_pri } +def test_bulk_authority_prefers_resource_manage_over_domain_delegate(monkeypatch): + service = _service(monkeypatch) + collections = [_collection(), _collection()] + monkeypatch.setattr(service, "_is_org_manager", lambda: False) + monkeypatch.setattr( + service, + "_has_domain_action", + lambda action: action == "permission_delegate", + ) + monkeypatch.setattr( + service.permission_helper, + "bulk_evaluate_collection_action", + lambda candidates, action, include_archived=False: { + candidate.id: SimpleNamespace( + allowed=True, + external_reason_code="permission.allowed", + ) + for candidate in candidates + }, + ) + + authority = service._require_bulk_collection_permission_authority(collections) + + assert authority == "resource_manager" + + +@pytest.mark.parametrize("target_count", [11, 50]) +def test_bulk_permission_response_uses_request_bounded_count_bucket( + monkeypatch, + target_count, +): + collections = [_collection() for _ in range(target_count)] + db = _BulkDb(collections) + service = _service(monkeypatch, db) + for collection in collections: + collection.organization_id = service.organization_id + monkeypatch.setattr( + service, + "_require_bulk_collection_permission_authority", + lambda candidates: "organization_manager", + ) + monkeypatch.setattr(service, "_lock_bundle_subject", lambda *args, **kwargs: None) + + response = service.mutate_permission_bundle_bulk( + KnowledgeCollectionPermissionBulkBundleRequest( + collection_ids=[collection.id for collection in collections], + operation="grant", + subject_type="user", + subject_id=uuid.uuid4(), + role_bundle="viewer", + ) + ) + + assert response.target_count_bucket == "11-50" + assert response.changed_count_bucket == "11-50" + assert response.unchanged_count_bucket == "0" + assert db.committed is True + assert db.rollback_count == 0 + + +def test_bulk_permission_response_validation_failure_rolls_back_before_commit( + monkeypatch, +): + collection = _collection() + db = _BulkDb([collection]) + service = _service(monkeypatch, db) + collection.organization_id = service.organization_id + monkeypatch.setattr( + service, + "_require_bulk_collection_permission_authority", + lambda candidates: "organization_manager", + ) + monkeypatch.setattr(service, "_lock_bundle_subject", lambda *args, **kwargs: None) + + def reject_response(**_kwargs): + raise ValueError("simulated response validation failure") + + monkeypatch.setattr( + knowledge_collection_service_module, + "KnowledgeCollectionPermissionBulkBundleResponse", + reject_response, + ) + + with pytest.raises(ValueError, match="^simulated response validation failure$"): + service.mutate_permission_bundle_bulk( + KnowledgeCollectionPermissionBulkBundleRequest( + collection_ids=[collection.id], + operation="grant", + subject_type="user", + subject_id=uuid.uuid4(), + role_bundle="viewer", + ) + ) + + assert db.committed is False + assert db.rollback_count == 1 + + +def test_management_item_projection_rechecks_mutation_not_read_authority(monkeypatch): + service = _service(monkeypatch) + collection = _collection() + calls = [] + expected = SimpleNamespace(order_revision="safe-revision") + monkeypatch.setattr( + service, + "_collection_or_hidden", + lambda collection_id: collection, + ) + monkeypatch.setattr( + service, + "_require_collection_membership_candidate_access", + lambda candidate: calls.append(candidate.id), + ) + monkeypatch.setattr(service, "_ordered_collection_items", lambda collection_id: []) + monkeypatch.setattr( + service, + "_items_response", + lambda collection_id, items: expected, + ) + + result = service.list_items_management_response(collection.id) + + assert result is expected + assert calls == [collection.id] + + def test_public_visibility_sets_only_candidate_flag(monkeypatch): db = _FakeDb() service = _service(monkeypatch, db=db) collection = _collection() - monkeypatch.setattr(service, "_collection_or_hidden", lambda collection_id: collection) + monkeypatch.setattr(service, "_locked_collection_or_hidden", lambda collection_id: collection) monkeypatch.setattr(service, "_require_org_manager", lambda: None) monkeypatch.setattr(service, "_collection_response", _collection_response) @@ -532,7 +751,7 @@ def test_link_item_requires_collection_manage_and_kb_manage(monkeypatch): collection = _collection() kb = SimpleNamespace(id=uuid.uuid4(), lifecycle_state="active") calls = [] - monkeypatch.setattr(service, "_collection_or_hidden", lambda collection_id: collection) + monkeypatch.setattr(service, "_locked_collection_or_hidden", lambda collection_id: collection) monkeypatch.setattr( service, "_require_collection_action", diff --git a/apps/shared/schemas/knowledge.py b/apps/shared/schemas/knowledge.py index 58bb5670d..433bb11db 100644 --- a/apps/shared/schemas/knowledge.py +++ b/apps/shared/schemas/knowledge.py @@ -157,7 +157,7 @@ class KnowledgeCollectionItemLinkRequest(BaseModel): model_config = ConfigDict(extra="forbid") knowledge_base_id: UUID - rank: int = Field(default=0, ge=0) + rank: int | None = Field(default=None, ge=0, deprecated=True) acknowledged_public_runtime_exposure: bool = False @@ -174,6 +174,12 @@ class KnowledgeCollectionItemResponse(BaseModel): class KnowledgeCollectionItemsResponse(BaseModel): items: list[KnowledgeCollectionItemResponse] = Field(default_factory=list) + order_revision: str = Field( + ..., + pattern=r"^ord_v1_[0-9a-f]{64}$", + ) + reorder_supported: bool = True + safe_reason_code: Literal["item_reorder_limit_exceeded"] | None = None class KnowledgeCollectionItemReorderEntry(BaseModel): @@ -187,7 +193,11 @@ class KnowledgeCollectionItemReorderRequest(BaseModel): model_config = ConfigDict(extra="forbid") items: list[KnowledgeCollectionItemReorderEntry] = Field( - ..., min_length=1, max_length=500 + ..., max_length=500 + ) + expected_order_revision: str = Field( + ..., + pattern=r"^ord_v1_[0-9a-f]{64}$", ) acknowledged_public_runtime_exposure: bool = False @@ -219,6 +229,32 @@ class KnowledgeCollectionPermissionBundleGrantRequest(BaseModel): role_bundle: KnowledgeCollectionRoleBundle +class KnowledgeCollectionPermissionBulkBundleRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + collection_ids: list[UUID] = Field(..., min_length=1, max_length=50) + operation: Literal["grant", "revoke"] + subject_type: Literal["team", "user"] + subject_id: UUID + role_bundle: KnowledgeCollectionRoleBundle + + @field_validator("collection_ids") + @classmethod + def validate_unique_collection_ids(cls, value: list[UUID]) -> list[UUID]: + if len(set(value)) != len(value): + raise ValueError("collection_ids must be unique") + return value + + +class KnowledgeCollectionPermissionBulkBundleResponse(BaseModel): + operation: Literal["grant", "revoke"] + subject_type: Literal["team", "user"] + role_bundle: KnowledgeCollectionRoleBundle + target_count_bucket: Literal["0", "1", "2-10", "11-50"] + changed_count_bucket: Literal["0", "1", "2-10", "11-50"] + unchanged_count_bucket: Literal["0", "1", "2-10", "11-50"] + + class KnowledgeCollectionPermissionResponse(BaseModel): permission_id: UUID subject_type: Literal["team", "user"] @@ -240,8 +276,8 @@ class KnowledgeDelegationSubject(BaseModel): class KnowledgeDelegationSubjectsResponse(BaseModel): - teams: list[KnowledgeDelegationSubject] = Field(default_factory=list) - users: list[KnowledgeDelegationSubject] = Field(default_factory=list) + subjects: list[KnowledgeDelegationSubject] = Field(default_factory=list) + next_cursor: str | None = None class KnowledgeCollectionVisibilityRequest(BaseModel): diff --git a/apps/shared/services/knowledge_permission_service.py b/apps/shared/services/knowledge_permission_service.py index 7e300fd3f..72a3101d9 100644 --- a/apps/shared/services/knowledge_permission_service.py +++ b/apps/shared/services/knowledge_permission_service.py @@ -89,13 +89,18 @@ def evaluate_collection_action( self, collection: KnowledgeCollection, action: str, + *, + include_archived: bool = False, ) -> KnowledgePermissionDecision: if action not in COLLECTION_PERMISSION_ACTIONS: return self._denied( reason_code="permission.invalid_action", external_reason_code="resource.hidden", ) - if not self._collection_in_scope(collection): + if not self._collection_in_scope( + collection, + include_archived=include_archived, + ): return self._denied(reason_code="resource.hidden") organization_auth_state = self._organization_auth_state() @@ -124,6 +129,8 @@ def bulk_evaluate_collection_action( self, collections: Iterable[KnowledgeCollection], action: str, + *, + include_archived: bool = False, ) -> dict[uuid.UUID, KnowledgePermissionDecision]: collection_list = list(collections) if not collection_list: @@ -133,7 +140,11 @@ def bulk_evaluate_collection_action( # hook. Production sessions always take the bounded bulk path below. if self.db is None: return { - collection.id: self.evaluate_collection_action(collection, action) + collection.id: self.evaluate_collection_action( + collection, + action, + include_archived=include_archived, + ) for collection in collection_list } @@ -148,7 +159,10 @@ def bulk_evaluate_collection_action( organization_auth_state = self._organization_auth_state() in_scope_by_id = { - collection.id: self._collection_in_scope(collection) + collection.id: self._collection_in_scope( + collection, + include_archived=include_archived, + ) for collection in collection_list } allowed_collection_ids: set[uuid.UUID] = set() @@ -857,11 +871,18 @@ def _can_consume_source_policy_grants(self) -> bool: self.organization_id, ) - def _collection_in_scope(self, collection: KnowledgeCollection) -> bool: + def _collection_in_scope( + self, + collection: KnowledgeCollection, + *, + include_archived: bool = False, + ) -> bool: + lifecycle_state = getattr(collection, "lifecycle_state", "active") return ( collection is not None and collection.organization_id == self.organization_id - and getattr(collection, "lifecycle_state", "active") == "active" + and lifecycle_state != "deleted" + and (lifecycle_state == "active" or include_archived) ) def _kb_in_scope( diff --git a/apps/shared/tests/db/test_knowledge_runtime_snapshot_disposable_postgres.py b/apps/shared/tests/db/test_knowledge_runtime_snapshot_disposable_postgres.py index 3c1264606..86bd54a3e 100644 --- a/apps/shared/tests/db/test_knowledge_runtime_snapshot_disposable_postgres.py +++ b/apps/shared/tests/db/test_knowledge_runtime_snapshot_disposable_postgres.py @@ -1,3 +1,5 @@ +# ruff: noqa: E402 + import json import os from concurrent.futures import ThreadPoolExecutor @@ -5,6 +7,16 @@ from uuid import UUID, uuid4 import pytest + +RUN_ENV = "NODEASE_RUN_DISPOSABLE_DB_TEST" +DB_PREFIX = "nodease_knowledge_snapshot_test" + +if os.getenv(RUN_ENV) != "1": + pytest.skip( + f"set {RUN_ENV}=1 to run disposable Knowledge snapshot evidence", + allow_module_level=True, + ) + from apps.shared.domain.knowledge_runtime_candidates import ( AnonymousPublicAudience, AuthenticatedAudience, @@ -36,9 +48,6 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import sessionmaker -RUN_ENV = "NODEASE_RUN_DISPOSABLE_DB_TEST" -DB_PREFIX = "nodease_knowledge_snapshot_test" - def _create_schema(engine) -> None: statements = ( diff --git a/apps/shared/tests/db/test_knowledge_safe_metadata_migration_postgres.py b/apps/shared/tests/db/test_knowledge_safe_metadata_migration_postgres.py index 488cb85f3..edca6d096 100644 --- a/apps/shared/tests/db/test_knowledge_safe_metadata_migration_postgres.py +++ b/apps/shared/tests/db/test_knowledge_safe_metadata_migration_postgres.py @@ -1,20 +1,24 @@ import uuid +import pytest from alembic.migration import MigrationContext from alembic.operations import Operations -from sqlalchemy import inspect, text - from apps.shared.alembic.versions import ( fa7c8d9e0f12_add_knowledge_base_safe_metadata as revision, ) from apps.shared.db.session import engine +from sqlalchemy import inspect, text +from sqlalchemy.exc import OperationalError def test_safe_metadata_revision_upgrades_and_downgrades_in_isolated_schema( monkeypatch, ): schema = f"test_safe_metadata_{uuid.uuid4().hex}" - connection = engine.connect() + try: + connection = engine.connect() + except OperationalError: + pytest.skip("PostgreSQL integration database is unavailable") transaction = connection.begin() try: connection.execute(text(f'CREATE SCHEMA "{schema}"')) diff --git a/apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py b/apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py index e7b35d2a0..ba537bb81 100644 --- a/apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py +++ b/apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py @@ -112,6 +112,43 @@ def test_bulk_collection_manager_override_skips_permission_rows(): assert helper.single_calls == [] +def test_archived_collection_is_hidden_by_default_but_available_to_admin_path(): + collection = _collection(1) + collection.lifecycle_state = "archived" + helper = _BulkCollectionHelper(auth_state=AUTH_STATE_MANAGER) + + runtime_decision = helper.evaluate_collection_action(collection, "read") + administration_decision = helper.evaluate_collection_action( + collection, + "read", + include_archived=True, + ) + + assert runtime_decision.allowed is False + assert runtime_decision.external_reason_code == "resource.hidden" + assert administration_decision.allowed is True + + +def test_bulk_archived_collection_requires_explicit_administration_scope(): + collection = _collection(1) + collection.lifecycle_state = "archived" + helper = _BulkCollectionHelper() + + runtime_decision = helper.bulk_evaluate_collection_action( + [collection], + "manage", + )[collection.id] + administration_decision = helper.bulk_evaluate_collection_action( + [collection], + "manage", + include_archived=True, + )[collection.id] + + assert runtime_decision.allowed is False + assert administration_decision.allowed is True + assert helper.bulk_calls == [((collection.id,), "manage")] + + def test_bulk_collection_invalid_action_is_fixed_safe_denial(): collection = _collection(1) helper = _BulkCollectionHelper() diff --git a/docs/decisions/ADR-0044-knowledge-collection-operational-management-boundary.md b/docs/decisions/ADR-0044-knowledge-collection-operational-management-boundary.md new file mode 100644 index 000000000..159ef41ae --- /dev/null +++ b/docs/decisions/ADR-0044-knowledge-collection-operational-management-boundary.md @@ -0,0 +1,191 @@ +# ADR-0044: Knowledge Collection 운영 관리 경계 + +Status: Accepted + +Related ADRs: [ADR-0010](ADR-0010-resource-access-403-404-policy.md), [ADR-0014](ADR-0014-knowledge-base-document-atom-and-collection-boundary.md), [ADR-0022](ADR-0022-incremental-hexagonal-architecture-adoption.md), [ADR-0034](ADR-0034-knowledge-delegated-administration-and-rbac-boundary.md), [ADR-0039](ADR-0039-knowledge-workflow-collection-routing-integration.md) + +## Context + +MBA-231~233은 Knowledge 위임 RBAC, runtime candidate resolution과 Workflow +Collection 선택을 구현했고 MBA-263은 Manual Collection의 안전 표시 이름을 관리 +UI에 연결했다. 그러나 운영 관리 surface에는 다음 공백이 남아 있다. + +- archive한 Manual Collection을 복구할 수 없다. +- item reorder API가 partial item set, duplicate rank와 concurrent link/unlink를 + 검출하지 않는다. +- 권한 대상 조회가 active Team/User 전체를 한 response로 반환한다. +- bundle은 부여만 가능하고 회수는 action row를 여러 번 삭제해야 한다. +- 같은 subject의 bundle을 여러 Collection에 원자적으로 적용할 수 없다. + +이 공백은 대규모 조직의 관리 UX뿐 아니라 stale write, partial permission mutation, +last-manage 상실과 cross-organization 정보 노출 위험을 만든다. 기존 additive allow와 +Collection/KB content-plane 분리는 유지하면서 lifecycle, order와 permission mutation의 +일관된 lock·transaction 경계가 필요하다. + +## Options Considered + +1. 현재 endpoint를 유지하고 Client에서 전체 목록, reorder 정합성과 bundle 조합을 + 계산한다. +2. Collection version column, role/bundle table와 permission deny/expiry를 추가해 관리 + model을 전면 재설계한다. +3. 기존 lifecycle/item/explicit permission row를 유지하고 Gateway application 경계에 + bounded query, opaque revision, Collection-first lock와 atomic bulk operation을 + 추가한다. + +## Decision + +선택지 3을 채택한다. + +### Lifecycle restore + +- `POST /api/v1/knowledge/collections/{collection_id}/restore`는 archived manual + Collection만 active로 전이한다. +- Organization manager, effective Collection `manage`, Knowledge domain + `lifecycle_manage`가 수행할 수 있다. +- Active restore retry는 새 mutation과 audit가 없는 idempotent success다. +- System-managed Collection은 connector/source owner가 관리하고 `source_deleted`는 + 별도 source recovery 없이 restore하지 않는다. +- Archive와 restore는 organization-scoped Collection row를 `FOR UPDATE`로 잠근 뒤 + 상태와 권한을 평가하고 canonical audit를 같은 transaction에 저장한다. +- Restore는 permission, membership, child KB `use` 또는 Workflow `route`를 만들지 + 않는다. + +### Exact item ordering + +- Item order revision은 current ordered membership의 Collection id, item id와 rank에서 + 계산한 versioned opaque digest다. DB authorization state나 capability가 아니다. +- Reorder는 현재 전체 item set, unique item id, unique contiguous `0..N-1` rank와 + `expected_order_revision`을 요구한다. +- Collection row를 먼저 잠그고 membership row를 deterministic order로 잠근 뒤 + revision과 exact set을 다시 비교한다. Stale/partial/foreign request는 mutation 없이 + safe conflict다. +- Link, unlink, reorder와 visibility mutation은 같은 Collection-first lock protocol을 + 사용한다. +- GET, link와 reorder 성공 response는 최신 전체 ordered projection과 revision을 + 반환하고 unlink는 `204` 뒤 재조회를 유지한다. 독립 action 모델에서 `manage`가 + `read`를 암묵적으로 만들지 않으므로 link/reorder 성공 projection은 완료된 mutation + authority를 다시 확인하는 safe management projection을 사용한다. +- Legacy link request의 optional `rank`는 호환 목적으로만 수용하고 무시한다. 신규 item은 + Collection lock 아래 current ordered set의 끝에 추가하고 전체 rank를 연속값으로 + 정규화한다. 명시 순서 변경은 revision을 요구하는 reorder endpoint만 담당한다. +- 초기 reorder surface는 500개 이하만 지원한다. 초과 상태는 + `item_reorder_limit_exceeded` fixed reason으로 비활성화한다. +- Empty Collection을 포함해 current 전체 set을 제출할 수 있고 current order와 같은 + request는 no-op이며 새 audit를 만들지 않는다. +- Public Collection의 reorder와 public 노출을 추가하는 link는 Organization manager + acknowledgement 뒤에도 source identity/connector 또는 source-managed child가 있으면 + public exposure approval primitive 부재 상태에서 `source_public_exposure_required`로 + fail-closed한다. + +### Bounded delegation subjects + +- Collection과 Knowledge domain delegation subject endpoint는 `subject_type=team|user`, + 최대 100자 safe prefix query, opaque cursor와 기본 25/최대 50 page contract를 + 공유한다. +- Endpoint별 authority를 subject query와 limit보다 먼저 확인한다. +- Current organization의 active Team 또는 active member User만 UUID keyset으로 + 조회한다. Offset과 total count는 사용하지 않는다. +- Team/User name만 검색하고 email, login principal, raw source identity를 검색하거나 + 반환하지 않는다. +- Cursor는 version, subject type, normalized query와 last UUID를 결합한 bounded + transport token이다. Secret이나 permission token이 아니며 context가 바뀌면 + fail-closed한다. + +### Bundle revoke and multi-Collection bulk + +- Viewer=`read`, Workflow Router=`read+route`, Maintainer=`read+manage`, Sync + Operator=`read+sync` mapping을 유지한다. +- Bundle은 저장 role이나 grant provenance가 아니라 action set이다. Revoke는 현재 + 존재하는 mapping action row만 삭제하며 없는 row는 idempotent unchanged다. +- Bulk endpoint는 unique Collection 1~50개, 한 subject, 한 bundle과 + `grant|revoke` operation만 받는다. +- Adapter는 Collection UUID 순으로 row를 잠그고 모든 target의 organization, + mutation authority, self/own-Team grant와 last-manage revoke 정책을 mutation 전에 + 검증한다. 하나라도 실패하면 permission과 audit 전체를 rollback한다. +- Grant는 active subject만 허용한다. Revoke는 inactive Team 또는 removed/deactivated + User의 기존 row를 정리할 수 있다. +- Target별 authorization 의미는 유지하되 organization/permission projection은 bounded + bulk query로 읽어 target 수만큼 N+1 query를 만들지 않는다. +- Actor가 모든 target의 effective Collection `manage`를 가진 경우 domain + `permission_delegate`도 보유했더라도 resource-manager authority를 우선한다. 일부 + target에만 `manage`가 있으면 domain-delegate 정책을 적용해 self/own-Team grant를 + 차단한다. +- 성공한 각 Collection mutation에는 같은 transaction의 canonical audit를 남긴다. + Response와 audit metadata는 raw subject/Collection label, target id 목록과 exact hidden + count를 포함하지 않고 fixed operation/action과 safe count bucket만 사용한다. + +### Architecture and data model + +- Lifecycle과 exact order flow는 FastAPI endpoint → Knowledge administration + application use case → repository/audit port → SQLAlchemy/audit adapter → Unit of Work + 순서를 따른다. +- Subject page, bundle revoke와 multi-Collection permission bulk는 기존 Gateway + Collection management service의 authorization/audit helper와 permission row model을 + 확장한다. 이 범위에서 별도 permission application 계층으로 대규모 이동하지 않으며 + endpoint는 request/response mapping만 담당한다. +- Controller는 request parsing, authentication dependency, use case 호출과 safe error + mapping만 담당한다. +- Existing lifecycle, membership rank와 Team/User Collection permission table을 + 재사용한다. Order revision column이나 bundle/role row를 추가하지 않는다. +- Subject prefix query index는 실제 PostgreSQL query plan이 필요성을 증명할 때만 + additive migration으로 추가한다. Alembic autogenerate diff만으로 index를 + 추가·삭제하지 않는다. +- Gateway와 Client의 reorder/subject response는 같은 release에서 coordinated cutover한다. + Revision 없는 reorder 또는 unbounded subject fallback은 제공하지 않는다. + +## Rationale + +기존 row model을 유지하면 MBA-231의 additive allow와 위임 정책을 바꾸지 않으면서 +운영 공백을 닫을 수 있다. Server-owned exact revision과 Collection-first lock는 Client가 +보안·동시성 권위가 되는 것을 막는다. UUID keyset page는 raw principal이나 전체 조직 +목록을 전송하지 않고도 검색 UX를 제공한다. Bundle을 action set으로 유지하면 겹치는 +bundle을 role provenance로 잘못 해석하는 문제를 피하고, all-or-nothing bulk는 실무자의 +반복 작업을 줄이면서 partial permission 상태를 막는다. + +## Consequences + +- Reorder Client는 전체 item set과 current revision을 보내야 하므로 legacy request는 + validation failure가 된다. +- Legacy link `rank`는 deprecated 호환 입력이며 실제 삽입 위치를 결정하지 않는다. +- Delegation subject Client는 subject type과 page contract를 사용해야 하며 전체 목록을 + 한 번에 받을 수 없다. +- Bundle revoke 뒤 action 조합은 어떤 role을 부여했던 기록이 아니라 현재 explicit + permission 상태만 나타낸다. +- Bulk는 한 target의 실패로 전체가 실패하므로 UI는 partial success로 표현하지 않는다. +- 500개 초과 Collection은 membership link/unlink를 계속 사용할 수 있지만 이번 + reorder UI는 비활성화된다. +- `sync` action과 Sync Operator bundle은 계속 부여·회수할 수 있으나 실제 KC sync + endpoint, Celery job과 progress UI는 MBA-265 범위다. +- Explicit deny, per-Collection expiry, bundle storage와 Collection route에서 child KB + use 자동 grant는 도입하지 않는다. + +## Affected Files + +- `docs/features/knowledge/{requirements,api_spec,component_spec,test_cases}.md` +- `apps/shared/schemas/knowledge.py` +- `apps/gateway/application/knowledge_administration/` +- `apps/gateway/adapters/db/knowledge_collection_operations.py` +- `apps/gateway/adapters/audit/knowledge_collection_operations.py` +- `apps/gateway/composition/knowledge_administration.py` +- `apps/gateway/api/v1/endpoints/knowledge.py` +- `apps/gateway/services/knowledge_collection_service.py` +- `apps/client/app/features/knowledge/` +- 관련 Gateway, Client와 PostgreSQL integration test + +## Implementation State + +MBA-264 구현은 manual restore, exact revision reorder, bounded subject page, bundle +revoke, multi-Collection atomic bulk와 해당 Client 관리 surface를 연결한다. 기존 DB row를 +재사용하므로 migration은 추가하지 않는다. 구현 완료 판정은 관련 Gateway/Shared/Client +자동화와 disposable PostgreSQL 동시성·rollback 검증 결과를 함께 사용한다. + +## Follow-up Review Notes + +- Disposable PostgreSQL 검증은 organization predicate, 실제 `FOR UPDATE`, concurrent + restore/reorder/bulk와 audit rollback을 fake condition 없이 실행한다. +- Subject prefix query의 `EXPLAIN (ANALYZE, BUFFERS)`를 확인하고 index가 필요하면 근거와 + 함께 별도 additive migration을 검토한다. +- MBA-265에서 `sync` action을 실제 비동기 실행에 연결할 때 idempotency, retry, + connector owner와 progress non-disclosure를 별도 결정한다. +- 운영에서 500개 초과 Collection reorder가 필요해지면 partial ordering/virtualization과 + revision semantics를 새 ADR로 검토한다. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 2e2eb9be2..b7635b25f 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -63,6 +63,7 @@ ADR 본문은 작성 시점의 결정 과정을 보존하는 기록 문서다. ` | [ADR-0041](ADR-0041-public-webhook-ingress-security-boundary.md) | Accepted | Public webhook ingress 보안 경계 | Public webhook은 query secret을 거부하고 단일 Bearer 또는 `X-Webhook-Secret` header만 허용한다. 인증 뒤 queue admission 전에 1 MiB, 5초, depth 20, 10,000 JSON node 제한을 적용하며 Repository Nginx와 production ingress도 query/header safe logging과 path-specific body guard를 검증한다. | | [ADR-0042](ADR-0042-security-alert-reconciliation-receipts.md) | Accepted | Security Alert reconciliation receipt 경계 | Processor별 receipt 부재를 미처리 audit의 durable 기준으로 사용한다. Event-time cursor는 rule 평가 순서와 관찰값으로 유지하고 late commit 발견 correctness에는 사용하지 않는다. | | [ADR-0043](ADR-0043-deployment-browser-origin-and-embedding-boundary.md) | Accepted | Deployment browser origin과 iframe embedding 경계 | Public Chatbot/Widget parent origin을 immutable deployment policy와 CSP `frame-ancestors`로 집행하고 first-party iframe API와 external direct JavaScript CORS를 분리한다. Missing/malformed policy와 projection 장애는 `'none'`으로 닫으며 public endpoint wildcard CORS를 제거한다. | +| [ADR-0044](ADR-0044-knowledge-collection-operational-management-boundary.md) | Accepted | Knowledge Collection 운영 관리 경계 | MBA-264는 manual Collection restore, exact revision reorder, bounded Team/User subject page, action-set bundle revoke와 1~50개 Collection atomic bulk 권한 변경을 Collection-first lock·transaction-bound audit 경계로 구현한다. KC sync 실행은 MBA-265로 분리한다. | ## 참고 보고서 diff --git a/docs/features/knowledge/api_spec.md b/docs/features/knowledge/api_spec.md index 4af462ff4..436efd4df 100644 --- a/docs/features/knowledge/api_spec.md +++ b/docs/features/knowledge/api_spec.md @@ -1,7 +1,7 @@ # Knowledge API Spec Status: Draft -이 문서는 Knowledge feature의 현재 API baseline과 목표 KB 통합 API 계약을 함께 기록한다. MBA-105 목표 API는 [ADR-0017](../../decisions/ADR-0017-knowledge-integration-provisional-implementation-baseline.md)의 임시 구현 baseline, Workflow RAG anonymous public-only runtime은 [ADR-0018](../../decisions/ADR-0018-workflow-rag-anonymous-public-only-runtime.md), MCP/API source connector와 incremental sync 경계는 [ADR-0020](../../decisions/ADR-0020-knowledge-mcp-incremental-sync-boundary.md), MBA-231 위임 관리와 KB RBAC cutover는 [ADR-0034](../../decisions/ADR-0034-knowledge-delegated-administration-and-rbac-boundary.md), direct KB와 명시 selected Collection의 internal runtime resolver는 [ADR-0036](../../decisions/ADR-0036-knowledge-runtime-candidate-resolution.md), 세부 구현 기준은 [implementation_baseline.md](implementation_baseline.md)를 따른다. Knowledge Skill 관련 API 경계는 [ADR-0015](../../decisions/ADR-0015-knowledge-skill-context-routing-boundary.md)를 따른다. +이 문서는 Knowledge feature의 현재 API baseline과 목표 KB 통합 API 계약을 함께 기록한다. MBA-105 목표 API는 [ADR-0017](../../decisions/ADR-0017-knowledge-integration-provisional-implementation-baseline.md)의 임시 구현 baseline, Workflow RAG anonymous public-only runtime은 [ADR-0018](../../decisions/ADR-0018-workflow-rag-anonymous-public-only-runtime.md), MCP/API source connector와 incremental sync 경계는 [ADR-0020](../../decisions/ADR-0020-knowledge-mcp-incremental-sync-boundary.md), MBA-231 위임 관리와 KB RBAC cutover는 [ADR-0034](../../decisions/ADR-0034-knowledge-delegated-administration-and-rbac-boundary.md), direct KB와 명시 selected Collection의 internal runtime resolver는 [ADR-0036](../../decisions/ADR-0036-knowledge-runtime-candidate-resolution.md), KC 운영 관리 계약은 [ADR-0044](../../decisions/ADR-0044-knowledge-collection-operational-management-boundary.md), 세부 구현 기준은 [implementation_baseline.md](implementation_baseline.md)를 따른다. Knowledge Skill 관련 API 경계는 [ADR-0015](../../decisions/ADR-0015-knowledge-skill-context-routing-boundary.md)를 따른다. ## Current Baseline Endpoints @@ -248,7 +248,7 @@ authority가 남아 있는지 검증한다. | --- | --- | --- | --- | | GET | `/api/v1/knowledge/domain-capabilities` | 현재 actor의 effective domain action과 UI capability 조회 | active organization member | | GET | `/api/v1/knowledge/domain-permissions` | Team/User domain grant 목록 | Organization manager | -| GET | `/api/v1/knowledge/domain-delegation-subjects` | Team-first safe 위임 대상 목록 | Organization manager | +| GET | `/api/v1/knowledge/domain-delegation-subjects` | bounded Team/User safe 위임 대상 page | Organization manager | | PUT | `/api/v1/knowledge/domain-permissions/teams/{team_id}/{permission_action}` | Team domain grant upsert | Organization manager | | DELETE | `/api/v1/knowledge/domain-permissions/teams/{team_id}/{permission_action}` | Team domain grant revoke | Organization manager | | PUT | `/api/v1/knowledge/domain-permissions/users/{user_id}/{permission_action}` | User domain grant upsert | Organization manager | @@ -471,6 +471,7 @@ Manual Collection 관리 API는 Knowledge 관리 영역에서 사용한다. Work | GET | `/api/v1/knowledge/collections/{collection_id}` | Collection 상세 | `collection.read`, Knowledge domain 관리 action, 또는 organization manager override | | PATCH | `/api/v1/knowledge/collections/{collection_id}` | safe name/description/metadata 수정 | `collection.manage`, private manual Collection의 domain `catalog_manage`, 또는 organization manager override | | DELETE | `/api/v1/knowledge/collections/{collection_id}` | physical delete가 아니라 archive 전이 | `collection.manage`, domain `lifecycle_manage`, 또는 organization manager override | +| POST | `/api/v1/knowledge/collections/{collection_id}/restore` | archived manual Collection을 active로 복구 | `collection.manage`, domain `lifecycle_manage`, 또는 organization manager override | List response는 `collections`, `can_create_collection`, `can_change_public_visibility`를 포함한다. 각 Collection row는 `id`, `name`, `description`, `is_system_managed`, `sync_state`, `lifecycle_state`, `visibility`, bucketed linked/active KB count, caller action flags, `safe_metadata`, timestamps만 포함한다. Raw source title/path/url/principal, hidden KB name/id, exact denied count는 반환하지 않는다. @@ -480,6 +481,8 @@ Update request는 visibility를 바꾸지 않는다. Public/private 전환은 `safe_metadata.safe_label`은 표시용 metadata일 뿐 권한이나 runtime capability가 아니다. Collection picker는 저장된 manual safe label을 다시 정제해 반환하고 값이 없으면 `null`을 반환한다. Raw Collection `name`/`description`을 fallback으로 반환하지 않으며 Client는 `null`에 generic `지식 Collection` label만 사용할 수 있다. +List의 `lifecycle_state` query는 `active`, `archived`, `deleted` 중 하나이며 관리 UI는 active와 archived를 별도 page로 조회한다. Archive와 restore는 Collection row를 잠근 뒤 상태와 권한을 다시 평가한다. Restore는 manual archived Collection만 `active`로 전이하며 active Collection에는 새 mutation/audit 없이 idempotent `204`를 반환한다. `deleted` 또는 organization 밖 대상은 hidden 처리하고 system-managed Collection은 source owner 경계로 거부하며 `sync_state=source_deleted`는 safe `409`로 차단한다. Restore는 기존 permission과 membership을 보존하지만 새 permission, child KB `use`, Workflow `route`를 만들지 않는다. + ### Collection Item Management | Method | Path | 목적 | 권한 | @@ -490,19 +493,27 @@ Update request는 visibility를 바꾸지 않는다. Public/private 전환은 | PATCH | `/api/v1/knowledge/collections/{collection_id}/items/reorder` | deterministic rank 변경 | private: `collection.manage` 또는 `catalog_manage`; public: Organization manager + acknowledgement | | GET | `/api/v1/knowledge/collections/{collection_id}/link-candidates` | link 가능한 KB 후보 | 해당 membership mutation 권한의 safe 후보만 반환 | -Item response는 `item_id`, `knowledge_base_id`, safe label, lifecycle/sync state, rank, caller action flags만 포함한다. Safe label은 유효한 `KnowledgeBase.safe_metadata.safe_label`, display-policy-approved source safe label, caller가 독립 KB `read`를 통과한 manual KB `name` 순으로 선택하고, 모두 사용할 수 없으면 generic `Knowledge Base`를 반환한다. Domain `catalog_manage`만으로 raw manual KB `name`을 fallback하지 않으며 link-candidate response도 같은 projection을 사용한다. `can_use_kb=false`인 item이 보일 수 있지만, 이는 runtime retrieval 가능성을 의미하지 않는다. Link/unlink는 같은 organization KB만 허용하며 archived/deleted KB는 link 대상에서 제외한다. Private Collection membership은 `collection.manage` + KB `manage`, 또는 domain `catalog_manage`로 관리할 수 있다. Public Collection의 link/unlink/reorder는 visibility 변경과 같은 public exposure mutation이므로 Organization manager와 `acknowledged_public_runtime_exposure=true`를 요구한다. Duplicate link는 MVP에서 idempotent success로 처리할 수 있다. +`GET /items`, link와 reorder 성공 response는 `items`, opaque `order_revision`, `reorder_supported`, optional fixed `safe_reason_code`를 포함하고 항상 최신 전체 ordered item projection을 반환한다. 각 item은 `item_id`, `knowledge_base_id`, safe label, lifecycle/sync state, rank, caller action flags만 포함한다. Safe label은 유효한 `KnowledgeBase.safe_metadata.safe_label`, display-policy-approved source safe label, caller가 독립 KB `read`를 통과한 manual KB `name` 순으로 선택하고, 모두 사용할 수 없으면 generic `Knowledge Base`를 반환한다. Domain `catalog_manage`만으로 raw manual KB `name`을 fallback하지 않으며 link-candidate response도 같은 projection을 사용한다. `can_use_kb=false`인 item이 보일 수 있지만, 이는 runtime retrieval 가능성을 의미하지 않는다. Standalone GET은 `collection.read`를 요구하지만 link/reorder 성공 응답은 완료한 mutation authority를 다시 확인한 safe management projection이므로 별도 `read` grant를 만들지 않는다. Link/unlink는 같은 organization KB만 허용하며 archived/deleted KB는 link 대상에서 제외한다. Private Collection membership은 `collection.manage` + KB `manage`, 또는 domain `catalog_manage`로 관리할 수 있다. Public Collection의 link/unlink/reorder는 visibility 변경과 같은 public exposure mutation이므로 Organization manager와 `acknowledged_public_runtime_exposure=true`를 요구한다. Source identity/connector Collection 또는 source-managed child가 연관된 public link/reorder는 approval primitive 부재 상태에서 `source_public_exposure_required`로 차단한다. Duplicate link는 MVP에서 idempotent success로 처리할 수 있다. Link request의 optional `rank`는 legacy caller 호환용 deprecated field이며 서버는 값을 무시하고 Collection lock 아래 끝에 append한 뒤 전체 rank를 연속값으로 정규화한다. + +Reorder request는 empty Collection을 포함한 현재 전체 item을 `{item_id, rank}`로 보내고 `expected_order_revision`을 반드시 포함한다. Item id와 rank는 각각 unique이고 rank는 정확히 `0..N-1`이어야 한다. 서버는 Collection과 membership row를 잠근 뒤 current revision, 현재 전체 item set과 request를 비교한다. Stale revision, 누락·추가 item 또는 concurrent link/unlink는 어떤 rank도 바꾸지 않는 safe `409`다. 같은 순서의 no-op은 새 audit를 만들지 않는다. 초기 관리 surface는 item 500개 이하만 reorder하며 초과 response는 `reorder_supported=false`, `safe_reason_code=item_reorder_limit_exceeded`로 고정한다. `order_revision`은 권한이나 조회 capability가 아니다. ### Collection Permission Management | Method | Path | 목적 | 권한 | | --- | --- | --- | --- | | GET | `/api/v1/knowledge/collections/{collection_id}/permissions` | permission grant 목록 | `collection.manage`, domain `permission_delegate`, 또는 organization manager | -| GET | `/api/v1/knowledge/collections/{collection_id}/delegation-subjects` | active Team/User safe 대상 목록 | permission 변경과 동일 | +| GET | `/api/v1/knowledge/collections/{collection_id}/delegation-subjects` | bounded active Team/User safe 대상 page | permission 변경과 동일 | | POST | `/api/v1/knowledge/collections/{collection_id}/permissions` | team/user 단일 action grant | permission 변경과 동일 | | POST | `/api/v1/knowledge/collections/{collection_id}/permissions/bundles` | role bundle을 explicit action row로 원자 적용 | permission 변경과 동일 | +| POST | `/api/v1/knowledge/collections/{collection_id}/permissions/bundles/revoke` | bundle action 집합의 explicit row를 원자 회수 | permission 변경과 동일 | | DELETE | `/api/v1/knowledge/collections/{collection_id}/permissions/{permission_id}` | grant revoke | permission 변경과 동일 | +| POST | `/api/v1/knowledge/collection-permissions/bulk-bundles` | 같은 subject/bundle을 1~50개 Collection에 원자 grant/revoke | 모든 target에 permission 변경 authority | + +단일 grant request는 `subject_type=team|user`, `subject_id`, `permission_action=read|route|manage|sync`만 허용한다. Bundle request의 `role_bundle`은 `viewer`, `workflow_router`, `maintainer`, `sync_operator`이며 각각 ADR-0034의 explicit action 집합을 한 transaction에서 upsert한다. 별도 role row나 inheritance를 만들지 않는다. Bundle revoke는 저장된 role을 찾지 않고 현재 존재하는 매핑 action row만 삭제한다. 따라서 Maintainer(`read+manage`)를 부여한 뒤 Viewer(`read`)를 회수하면 `manage` row는 유지되며 UI도 이를 다시 Maintainer role로 추론하지 않는다. 없는 row의 회수는 idempotent unchanged다. Domain delegator의 self/own-Team grant는 `409 policy.blocked`로 차단하고, 마지막 manage 경로 회수는 safe denial 또는 Organization manager recovery를 요구한다. + +Delegation subject query는 `subject_type=team|user`를 필수로 받고 optional `query`(정규화된 safe prefix, 최대 100자), opaque `cursor`, `limit`(기본 25, 최대 50)를 사용한다. Response는 `subjects[{subject_type, subject_id, subject_safe_label}]`와 optional `next_cursor`만 반환한다. 서버는 endpoint별 authority를 먼저 검증한 뒤 current organization의 active Team 또는 active member User를 UUID keyset으로 `limit + 1` 조회한다. Team/User name만 검색하고 email, login principal, raw source identity와 total count는 검색하거나 반환하지 않는다. 같은 page 계약을 Organization manager 전용 `/api/v1/knowledge/domain-delegation-subjects`에도 적용하며 cursor는 subject type과 정규화된 query가 바뀌면 거부한다. -단일 grant request는 `subject_type=team|user`, `subject_id`, `permission_action=read|route|manage|sync`만 허용한다. Bundle request의 `role_bundle`은 `viewer`, `workflow_router`, `maintainer`, `sync_operator`이며 각각 ADR-0034의 explicit action 집합을 한 transaction에서 upsert한다. 별도 role row나 inheritance를 만들지 않는다. Domain delegator의 self/own-Team grant는 `409 policy.blocked`로 차단하고, 마지막 manage 경로 회수는 safe denial 또는 Organization manager recovery를 요구한다. +Bulk bundle request는 `collection_ids`(unique, 1~50), `operation=grant|revoke`, `subject_type`, `subject_id`, `role_bundle`을 받는다. 서버는 UUID 정렬 순서로 Collection을 잠그고 모든 target의 organization scope, permission authority, self/own-Team grant 차단과 last-manage revoke 조건을 mutation 전에 검증한다. Actor가 모든 target의 effective `manage`를 가진 경우 resource authority를 domain `permission_delegate`보다 우선하고, 일부 target만 `manage` 가능한 경우 domain-delegate self/own-Team 차단을 유지한다. 하나라도 실패하면 permission과 audit 전체를 rollback한다. Grant는 active subject만 허용하고 revoke는 inactive Team 또는 removed/deactivated User의 기존 row 정리를 허용한다. Response는 `operation`, `subject_type`, `role_bundle`, `target_count_bucket`, `changed_count_bucket`, `unchanged_count_bucket`만 반환하고 Collection/subject id, label 또는 실패 target index를 반복하지 않는다. ### Public Visibility diff --git a/docs/features/knowledge/component_spec.md b/docs/features/knowledge/component_spec.md index 5e2ac5a20..1cf225b0f 100644 --- a/docs/features/knowledge/component_spec.md +++ b/docs/features/knowledge/component_spec.md @@ -1,7 +1,7 @@ # Knowledge Component Spec Status: Draft -MBA-105 구현 baseline, 운영 기본값, permission helper output, active version finalization, resource hiding matrix는 [implementation_baseline.md](implementation_baseline.md)를 따른다. Workflow RAG에서 `execution_subject`가 없는 MVP public-only runtime은 [ADR-0018](../../decisions/ADR-0018-workflow-rag-anonymous-public-only-runtime.md)을 따른다. MCP/API source connector와 incremental sync 경계는 [ADR-0020](../../decisions/ADR-0020-knowledge-mcp-incremental-sync-boundary.md)을 따른다. Direct KB와 명시 selected Collection의 Workflow runtime candidate 해석은 [ADR-0036](../../decisions/ADR-0036-knowledge-runtime-candidate-resolution.md)을 따른다. +MBA-105 구현 baseline, 운영 기본값, permission helper output, active version finalization, resource hiding matrix는 [implementation_baseline.md](implementation_baseline.md)를 따른다. Workflow RAG에서 `execution_subject`가 없는 MVP public-only runtime은 [ADR-0018](../../decisions/ADR-0018-workflow-rag-anonymous-public-only-runtime.md)을 따른다. MCP/API source connector와 incremental sync 경계는 [ADR-0020](../../decisions/ADR-0020-knowledge-mcp-incremental-sync-boundary.md)을 따른다. Direct KB와 명시 selected Collection의 Workflow runtime candidate 해석은 [ADR-0036](../../decisions/ADR-0036-knowledge-runtime-candidate-resolution.md)을 따른다. KC lifecycle, item 순서와 권한 운영 경계는 [ADR-0044](../../decisions/ADR-0044-knowledge-collection-operational-management-boundary.md)을 따른다. ## Domain Components @@ -28,7 +28,10 @@ MBA-105 구현 baseline, 운영 기본값, permission helper output, active vers | Knowledge Permission Helper | Collection `read`, collection `route`, KB use, source ACL freshness/requester authorization을 bulk 평가한다 | Router와 controller는 permission row가 아니라 helper 결과를 소비해야 한다 | | Knowledge Administration Application | Organization manager의 domain grant/revoke, active subject grant validation, stale grant permission-row revoke와 transaction-bound audit를 조율한다 | Grant subject lock과 revoke permission-row lock을 분리하고 controller가 subject 활성 상태를 추정하지 않는다 | | Workflow Runtime Knowledge Candidate Resolver | Direct KB와 명시 selected Collection을 current authenticated/anonymous audience, lifecycle/readiness, route/use/source gate로 해석하고 direct-first/Collection-round-robin 20-KB set을 만든다 | Shared pure policy + Workflow Engine application/port + PostgreSQL snapshot adapter다. Gateway Builder resolver를 import하지 않고 retrieval/provider를 호출하지 않는다 | -| Knowledge Collection Management Service | Manual Collection CRUD, item link/unlink/reorder, permission grant/revoke, visibility transition을 조율한다 | Controller에 business logic을 두지 않고, Collection 권한과 KB content 권한을 분리해서 검증한다 | +| Knowledge Collection Management Service | Manual Collection CRUD, item link/unlink, visibility, bounded subject page와 single/bundle/bulk permission mutation을 조율한다 | Collection-first lock, last-manage, self-escalation, all-or-nothing을 service가 판단하고 Collection 권한과 KB content 권한을 분리한다 | +| Knowledge Collection Administration Application | Restore와 exact reorder의 authorization·lock·audit·transaction 순서를 port 경계로 조율한다 | Endpoint나 Client가 lifecycle, order revision 또는 persistence policy를 판단하지 않는다 | +| Collection Management PostgreSQL Adapter | Lifecycle/order용 organization-scoped row projection, Collection-first `FOR UPDATE`와 membership lock을 제공한다 | Raw principal을 projection하지 않고 repository port 밖으로 ORM entity를 전달하지 않는다 | +| Collection Management Audit Adapter | 변경된 Collection마다 allowlisted canonical data-change audit를 같은 transaction에 추가한다 | Raw subject/Collection label, request payload, hidden target list와 exact count를 저장하지 않는다 | | Knowledge Document Response Projector | 내부 `documents.meta_info`에서 safe operational field만 allowlist projection한다 | Encrypted config, connection/source identifier, DB/source config와 unknown nested field를 API response로 전달하지 않는다 | | Knowledge RAG Recommendation Adapter | `StructuredRequest` 기반 safe intent summary, node purpose summary, knowledge requirement, pending resolution reference를 받아 safe KB recommendation과 LLM node RAG option 후보를 만든다 | Raw natural language 전체를 받지 않고 권한 판단을 직접 하지 않는다. HTTP/serialized boundary에서는 `KnowledgeCandidateResolver`가 만든 server-issued reference만 사용하고, full safe candidate set 객체는 같은 backend 내부 service call에서만 ranking input으로 사용할 수 있다. 초기 구현은 `candidate_type=knowledge_base`만 반환하고 Collection은 safe summary metadata로만 제공한다 | | Knowledge Skill Registry | Provider-neutral Knowledge Skill, version, owner/review state, freshness/eval status를 관리한다 | Skill은 빌더 단계 LLM node의 RAG 옵션 후보이며 권한 source나 source of truth가 아니다 | @@ -145,14 +148,17 @@ Knowledge Collection 관리 UI는 Workflow Builder가 아니라 Knowledge 관리 필수 surface: - Collection 목록: safe name/description, manual/system-managed, lifecycle/sync state, visibility, bucketed linked/active KB count, caller action flags를 표시한다. +- Lifecycle 관리: active/archived 탭을 분리하고 archived manual Collection에만 restore action을 제공한다. 복구 성공 뒤 active 목록을 다시 조회하며 restore가 permission이나 Workflow route를 부여한다고 표시하지 않는다. System-managed 또는 source-deleted 제한은 고정 safe reason으로 표현한다. - Collection 생성/수정: organization manager 또는 domain `catalog_manage`가 private manual Collection을 생성한다. 관리 form은 raw 관리용 `name`과 Workflow picker용 `안전 표시 이름`을 별도 필드로 제공하고 둘 다 nonblank일 때만 새 Manual Collection 생성을 제출한다. 편집 가능한 Manual Collection은 `safe_metadata.safe_label`을 안전 표시 이름 필드에 복원하며, 값이 없는 기존 Collection에는 보완 필요 안내를 표시하고 label 입력 전 정보 저장을 비활성화한다. UI는 관리용 `name`을 label로 자동 복사하지 않고 create/update request의 `safe_metadata.safe_label`로 명시적으로 전송한다. Update는 서버가 반환한 다른 safe metadata를 보존하면서 label을 교체한다. Delegated create는 client 입력과 무관하게 private다. `is_system_managed`나 public visibility는 일반 create/edit form에서 직접 설정하지 않는다. - Collection 생성과 public visibility control은 `domain-capabilities`의 분리된 boolean capability를 사용한다. Client는 Organization manager 여부나 action 조합으로 권한을 재구성하지 않으며 capability refresh 실패 시 이전 create/public 허용 상태를 즉시 닫는다. Collection list의 중복 management capability는 호환 projection이고 보안 판정 근거가 아니며, 모든 mutation은 Gateway가 다시 인가한다. - Collection 상세: item, permission, visibility, sync/system state를 분리해서 표시한다. -- Item manager: linked KB safe label, lifecycle/sync state, rank, `can_manage_kb`, `can_use_kb`를 표시한다. 유효한 safe label이 없고 caller가 KB `read`를 통과하지 못하면 generic label을 사용하며, domain `catalog_manage`만으로 manual KB `name`을 표시하지 않는다. Private membership은 `collection.manage` + KB `manage` 또는 domain `catalog_manage`, public membership은 Organization manager acknowledgement 경계를 따른다. -- Permission panel: server가 반환한 safe Team/User 대상 목록을 사용하고 Team을 기본값으로 둔다. `collection.manage` 또는 domain `permission_delegate` actor가 `read`, `route`, `manage`, `sync` additive allow를 grant/revoke할 수 있다. +- Item manager: linked KB safe label, lifecycle/sync state, rank, `can_manage_kb`, `can_use_kb`를 표시한다. 유효한 safe label이 없고 caller가 KB `read`를 통과하지 못하면 generic label을 사용하며, domain `catalog_manage`만으로 manual KB `name`을 표시하지 않는다. Private membership은 `collection.manage` + KB `manage` 또는 domain `catalog_manage`, public membership은 Organization manager acknowledgement 경계를 따른다. 500개 이하 item은 위/아래와 keyboard 조작으로 local draft를 만들고 current order revision과 전체 item set을 명시적으로 저장한다. Save/cancel/dirty/stale 상태를 구분하고 conflict 때 local draft를 조용히 덮어쓰지 않는다. +- Permission panel: bounded server-side subject combobox를 사용하고 Team을 기본값으로 둔다. Subject type/query/cursor가 바뀌면 stale page를 폐기하고 loading/empty/error/retry/next-page를 제공하되 email이나 raw id를 visible fallback label로 쓰지 않는다. `collection.manage` 또는 domain `permission_delegate` actor가 `read`, `route`, `manage`, `sync` additive allow를 grant/revoke할 수 있다. - Public visibility warning flow: organization manager, explicit acknowledgement, safe exposure summary를 요구한다. - Public Collection item link/unlink/reorder도 같은 public exposure warning과 acknowledgement를 요구한다. -- Collection role preset은 Viewer, Workflow Router, Maintainer, Sync Operator를 제공하되 저장 시 explicit action row를 transactionally 적용하고 KB `use`가 포함되지 않음을 표시한다. +- Collection role preset은 Viewer, Workflow Router, Maintainer, Sync Operator를 제공하되 저장 시 explicit action row를 transactionally 적용하고 KB `use`가 포함되지 않음을 표시한다. Bundle 회수도 같은 action 집합의 현재 row를 한 번에 제거하며 저장된 role이나 inheritance처럼 표현하지 않는다. +- Bulk permission 관리: 같은 subject와 bundle을 선택한 1~50개 Collection에 grant/revoke를 한 요청으로 적용한다. UI는 all-or-nothing임을 설명하고 partial success를 만들거나 표시하지 않으며 성공/실패에서 hidden target identity를 노출하지 않는다. +- Permission row는 Team과 User direct source를 분리해 표시한다. User direct row 회수 뒤 Team grant가 남을 수 있음을 안내하고 action 조합을 role provenance로 재구성하지 않는다. 금지 surface: diff --git a/docs/features/knowledge/requirements.md b/docs/features/knowledge/requirements.md index 850c8d467..e575cee5e 100644 --- a/docs/features/knowledge/requirements.md +++ b/docs/features/knowledge/requirements.md @@ -142,6 +142,12 @@ Workflow canvas에는 독립형 RAG 실행 노드를 도입하지 않는다. Kno - FR-102 (MBA-233): Agent Builder, cost optimizer, compare/copy/import, model-routing refresh와 deployment snapshot은 명시적으로 Collection selection을 편집하지 않는 한 `knowledgeCollections`를 보존해야 한다. 현재 Agent Builder recommendation은 direct KB만 materialize하고 Collection을 자동 선택하지 않으며 pre-execution sync는 explicit direct KB만 처리한다. - FR-103 (MBA-233): Public app/deployment graph projection은 `knowledgeBases`와 `knowledgeCollections`를 모두 제거해야 한다. Direct KB evidence는 기존 authorized lineage projection과 KB-local `rank`를 유지할 수 있다. Collection에서 동적으로 유래한 evidence는 result/durable trace/audit에 child KB/document/chunk ID나 KB-local `rank`를 저장하지 않으며, result와 품질 trace에는 정렬·dedupe·top-k 이후의 전역 `evidence_rank`만 저장할 수 있다. Audit은 노드 단위 candidate/result count bucket으로 집계한다. Durable trace/log/audit에는 routing mode, count bucket, limit/failure flag와 fixed safe reason만 허용하고 Collection/hidden KB identity, child structure, raw graph/query/source/credential/provider payload를 추가하지 않아야 한다. - FR-104 (MBA-233): `knowledgeCollections`는 기존 graph JSONB의 additive field이며 MBA-233에서 새 DB relation, `llm_node_versions` column 또는 static membership snapshot을 추가하지 않는다. 새 field는 Worker-first 배포와 queue drain 뒤 Gateway write, Client 순서로 노출하고 rollback은 역순으로 수행해야 한다. +- FR-105 (MBA-264): Manual Collection lifecycle 관리는 active/archived 목록과 archived-to-active restore를 제공해야 한다. Restore는 Organization manager, effective Collection `manage`, 또는 Knowledge domain `lifecycle_manage`만 수행할 수 있고 manual archived Collection에만 적용한다. Active Collection restore는 새 audit 없이 idempotent success이며, system-managed Collection은 source owner가 관리하고 `source_deleted` Collection은 fail-closed한다. Archive와 restore는 Collection row lock, organization scope, transaction-bound canonical audit를 공유하고 permission이나 Workflow route capability를 새로 만들지 않아야 한다. +- FR-106 (MBA-264): Collection item reorder는 empty Collection을 포함한 current 전체 membership set, unique item id, `0..N-1`의 unique contiguous rank와 opaque `expected_order_revision`을 요구한다. Link, unlink, reorder와 visibility mutation은 같은 Collection row lock protocol을 사용해야 하며 stale revision, 누락·추가 item, concurrent membership 변경은 아무 mutation 없이 safe conflict로 닫는다. Legacy link `rank`는 호환 입력으로만 수용하고 서버가 Collection 끝에 append한 뒤 연속 rank로 정규화한다. Item 조회, link와 reorder 성공 응답은 최신 전체 ordered projection과 revision을 반환하며 mutation caller가 별도 `read` 없이 유효한 mutation authority만 가진 경우에도 safe management projection을 반환한다. 초기 관리 UI는 500개 이하 Collection만 reorder하고 초과 시 고정 safe reason으로 비활성화한다. Public Collection의 source identity/connector 또는 source-managed child는 public exposure primitive가 없으면 acknowledgement만으로 link/reorder할 수 없다. +- FR-107 (MBA-264): Collection 및 Knowledge domain delegation subject 조회는 authorization 이후 current organization의 active Team 또는 active member User를 server-side prefix search와 UUID keyset cursor로 page 처리해야 한다. `subject_type`은 명시하고 기본 page size는 25, 최대 50이며 total count, email, login principal, raw source identity를 검색하거나 반환하지 않는다. Cursor는 subject type과 정규화된 query에 결합한 opaque transport token일 뿐 permission token이 아니다. +- FR-108 (MBA-264): Collection role bundle은 별도 role 또는 grant provenance가 아니라 explicit action 집합이다. Bundle revoke는 현재 존재하는 매핑 action row를 한 transaction에서 제거하고 없는 row는 idempotent unchanged로 처리한다. Bundle이 겹치더라도 저장된 role을 추론하지 않으며, Team/User direct permission source와 action row를 UI에서 그대로 구분한다. +- FR-109 (MBA-264): 하나의 Team/User bundle을 1개 이상 50개 이하 Collection에 grant 또는 revoke하는 bulk operation은 Collection UUID 정렬 lock, 모든 target의 organization·authority·last-manage 사전 검증, mutation과 canonical audit의 all-or-nothing transaction을 사용한다. Grant는 active subject만 허용하고 revoke는 inactive subject의 기존 row 정리를 허용한다. Domain delegator self/own-Team grant 차단과 current actor last-manage 보호는 single과 bulk에 동일하게 적용한다. 응답과 오류는 target/subject identity나 정확한 hidden count 대신 operation과 safe count bucket만 포함한다. +- FR-110 (MBA-264): MBA-264는 기존 additive allow permission model과 Collection/KB 권한 분리를 유지한다. Explicit deny, per-Collection permission expiry, bundle 전용 저장 row, Collection `route`에서 child KB `use` 자동 부여, KC sync 실행 endpoint/job/UI, Workflow graph/runtime resolver 변경은 도입하지 않는다. ## Policies And Edge Cases diff --git a/docs/features/knowledge/test_cases.md b/docs/features/knowledge/test_cases.md index 32ff61032..814fa959f 100644 --- a/docs/features/knowledge/test_cases.md +++ b/docs/features/knowledge/test_cases.md @@ -1,7 +1,7 @@ # Knowledge Test Cases Status: Draft -Verified Against: `origin/dev @ 32fb602f` +Verified Against: `feature/mba-264 @ 8832d23b` 이 문서는 현재 RAG 동작과 목표 KB 통합 모델에 필요한 테스트 범위를 함께 기록한다. MBA-105 목표 모델 테스트는 [ADR-0017](../../decisions/ADR-0017-knowledge-integration-provisional-implementation-baseline.md)과 [implementation_baseline.md](implementation_baseline.md)의 임시 baseline을 기준으로 구현 blocker가 된다. ## Unit Tests @@ -410,6 +410,28 @@ Verified Against: `origin/dev @ 32fb602f` - Collection 관리 UI는 `can_manage_collection`, `can_manage_kb`, `can_use_kb`를 혼동하지 않고, item list에 보이는 KB가 runtime retrieval 가능성을 보장하지 않는다는 상태를 표현한다. - Collection 관리 UI는 raw source title/path/url/principal, hidden KB name/id, exact denied count를 표시하지 않는다. - Collection list는 safe redacted name/description과 non-color text label이 있는 state badge를 표시한다. +- Collection lifecycle list는 active와 archived query를 분리한다. Archived manual Collection restore는 Organization manager, Collection `manage`, domain `lifecycle_manage`에서 허용하고 active restore retry는 새 mutation/audit 없는 `204`여야 한다. Deleted/cross-organization 대상은 hidden, system-managed 대상은 source-owner policy denial, `source_deleted` 대상은 safe conflict로 처리한다. +- Archive와 restore는 실제 PostgreSQL에서 같은 Collection row를 `FOR UPDATE`로 잠근다. 두 concurrent restore는 상태 전이와 canonical audit를 한 번만 만들고 audit flush/commit 실패는 lifecycle mutation과 audit를 모두 rollback한다. Restore가 permission, membership, child KB `use` 또는 Workflow `route` row를 만들면 테스트 실패다. +- Item GET, link와 reorder success response는 최신 전체 ordered item projection, `order_revision`, `reorder_supported`, optional fixed `safe_reason_code`를 같은 의미로 반환한다. Unlink 204 뒤 GET revision은 이전 값과 달라야 한다. +- Reorder는 current 전체 item id set, unique item id, unique contiguous `0..N-1` rank와 `expected_order_revision`을 요구한다. Duplicate item overwrite, partial request, missing/foreign/extra item, duplicate/gapped/negative rank, malformed 또는 다른 Collection revision은 아무 mutation 없이 거부한다. Current order no-op에는 새 audit를 만들지 않는다. Deleted/cross-organization target은 authority 또는 item order 조회 전에 hidden `404`로 처리한다. +- Item 0개와 1개는 stable revision을 만들 수 있고 500개는 reorder 가능하다. 501개부터 `reorder_supported=false`, `safe_reason_code=item_reorder_limit_exceeded`이며 mutation을 허용하지 않는다. Token은 권한이나 item 조회 capability로 사용되지 않는다. +- Empty Collection reorder는 `items=[]`와 current revision을 수용해 no-op safe projection을 반환한다. Link의 legacy `rank` 값은 삽입 위치를 바꾸지 않고 새 item은 끝에 append되며 전체 rank가 연속값으로 정규화되어야 한다. +- Link/reorder mutation authority는 있지만 별도 Collection `read`가 없는 actor도 mutation commit 뒤 safe management projection을 받아야 한다. 성공한 DB mutation 뒤 response projection의 권한 오류로 실패 응답을 반환하면 테스트 실패다. +- PostgreSQL concurrent reorder/reorder는 먼저 commit한 한 요청만 성공하고 두 번째는 lock 뒤 stale conflict가 된다. Reorder/link, reorder/unlink, reorder/visibility도 같은 Collection-first lock protocol을 사용해 membership set, rank, public acknowledgement가 stale 판단으로 우회되지 않아야 한다. 최종 rank는 contiguous하고 audit failure는 전체 rank를 rollback한다. +- Public Collection/KB의 source identity뿐 아니라 Collection `source_connector_ref`도 public link/reorder/visibility에서 같은 `source_public_exposure_required` fail-closed 정책을 적용한다. +- Delegation subject endpoint는 authority 확인 전 Team/User SELECT를 실행하지 않는다. Collection endpoint는 Organization manager, Collection `manage`, domain `permission_delegate`; domain endpoint는 Organization manager를 먼저 검증한다. +- Subject page는 `subject_type=team|user`, safe prefix query 100자 이하, opaque cursor, 기본 limit 25/최대 50을 적용한다. Current organization active Team과 active member User만 반환하고 inactive/cross-organization row, email, login principal, raw source identity와 total count를 포함하지 않는다. +- Subject prefix search는 whitespace와 case를 일관되게 처리하고 `%`, `_`, quote를 SQL wildcard/injection으로 해석하지 않는다. 동일 safe label은 UUID keyset tie-break로 page 간 duplicate/skip 없이 반환한다. Subject type/query가 다른 cursor와 malformed/oversized cursor는 입력값을 echo하지 않는 bounded safe error다. +- Subject combobox는 Team을 기본값으로 두고 debounce, loading, empty, error/retry, next-page, stale response 폐기와 keyboard navigation을 제공한다. Type/query 변경 시 stale selection을 정리하고 email/raw UUID를 visible label fallback으로 사용하지 않는다. +- Bundle revoke는 Viewer=`read`, Workflow Router=`read+route`, Maintainer=`read+manage`, Sync Operator=`read+sync`의 현재 explicit row만 한 transaction에서 제거한다. 없는 row는 unchanged이며 Maintainer grant 뒤 Viewer revoke 결과는 `manage`만 남는다. UI/API가 이를 저장된 Maintainer role로 추론하면 테스트 실패다. +- Bundle grant/revoke 일부 row 또는 audit 저장 실패는 전체 rollback한다. Grant는 active subject만 허용하고 inactive Team/removed User의 기존 permission은 revoke할 수 있다. 어떤 bundle도 child KB `use`를 만들지 않는다. +- Domain `permission_delegate` actor의 self/own-active-Team bundle grant는 차단하고 revoke는 last-manage 검증 뒤 허용한다. Current actor의 마지막 `manage` 경로를 제거하는 single/bundle/bulk revoke는 Organization manager recovery가 아닌 경우 전체 거부하며 independent manage path가 있으면 허용한다. +- 모든 bulk target에 effective Collection `manage`가 있는 actor는 domain `permission_delegate`도 함께 보유했다는 이유만으로 self/own-Team grant가 차단되지 않는다. 일부 target에만 resource `manage`가 있으면 domain-delegate self-escalation 차단을 적용한다. +- Multi-Collection bulk bundle은 unique Collection id 1~50개와 한 subject/bundle/operation만 받는다. 0개, 51개, duplicate/malformed id와 invalid enum은 mutation 전에 거부한다. Collection을 UUID 순으로 잠그고 모든 target의 organization/authority/last-manage를 사전 검증하며 한 target 실패 시 permission/audit 전체를 rollback한다. +- Bulk response는 operation, subject type, bundle과 target/changed/unchanged count bucket만 포함하고 Collection/subject id, label, 실패 index나 exact hidden count를 반환하지 않는다. 11~50개 count는 `11-50` bucket을 사용하고 response validation은 permission/audit commit 전에 완료한다. 1, 2, 50개 grant/revoke와 retry는 duplicate permission row 없이 deterministic해야 한다. +- PostgreSQL bulk 검증은 cross-organization target 혼합, N-1 authorized + 1 unauthorized, 한 target의 last-manage 실패, 반대 순서 target을 가진 concurrent request와 audit failure를 포함한다. Query/lock capture는 organization predicate와 실제 `FOR UPDATE`를 확인하고 target 수만큼 authorization query가 늘어나는 N+1을 허용하지 않는다. +- Permission UI는 Team row와 User direct row를 분리한다. 같은 action에 두 source가 있을 때 direct revoke 뒤 Team effective allow가 남는 사실을 표시하고 revoke success를 전체 접근 차단으로 잘못 표현하지 않는다. +- MBA-264는 Collection sync 실행 endpoint/job/UI, explicit deny, per-Collection expiry, bundle 저장 row, child KB `use` 자동 grant와 Workflow graph/runtime 변경을 추가하지 않는다. - Source metadata에서 유래한 system-managed collection display name/description은 storage/display 전에 redaction, cap, display-policy approval을 거친다. - KB detail은 hidden source path를 누출하지 않으면서 sync failed, source ACL stale, source deleted, archived, deleted state를 구분한다. - Remediation queue는 raw connector exception string이 아니라 safe reason code와 retryability를 표시한다.