From c3b2b365fba1e8ee996c53e8703277975f0f7f4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Tue, 14 Jul 2026 18:05:04 +0900 Subject: [PATCH 1/7] =?UTF-8?q?docs:=20KC=20=EC=9A=B4=EC=98=81=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B3=84=EC=95=BD=EA=B3=BC=20=EA=B2=B0=EC=A0=95=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...lection-operational-management-boundary.md | 171 ++++++++++++++++++ docs/decisions/README.md | 1 + docs/features/knowledge/api_spec.md | 21 ++- docs/features/knowledge/component_spec.md | 14 +- docs/features/knowledge/requirements.md | 6 + docs/features/knowledge/test_cases.md | 18 ++ 6 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 docs/decisions/ADR-0044-knowledge-collection-operational-management-boundary.md 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..3a4f418a7 --- /dev/null +++ b/docs/decisions/ADR-0044-knowledge-collection-operational-management-boundary.md @@ -0,0 +1,171 @@ +# 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` 뒤 재조회를 유지한다. +- 초기 reorder surface는 500개 이하만 지원한다. 초과 상태는 + `item_reorder_limit_exceeded` fixed reason으로 비활성화한다. +- Current order와 같은 request는 no-op이며 새 audit를 만들지 않는다. + +### 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를 만들지 않는다. +- 성공한 각 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 + +- 신규 flow는 FastAPI endpoint → Knowledge administration application use case → + repository/audit port → SQLAlchemy/audit adapter → Unit of Work 순서를 따른다. +- 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가 된다. +- 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_management.py` +- `apps/gateway/adapters/audit/knowledge_collection_management.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 + +이 ADR은 MBA-264의 승인된 목표 계약이다. ADR을 추가하는 문서 커밋 시점에는 기존 +archive, partial reorder, unbounded subject list와 action-row revoke baseline이 남아 +있다. 구현 완료 여부는 코드와 관련 자동화 검증 결과로 판정하며 문서 자체가 완료를 +의미하지 않는다. + +## Follow-up Review Notes + +- 실제 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..5fe2544f3 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 가능성을 의미하지 않는다. 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로 처리할 수 있다. + +Reorder request는 현재 전체 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 전에 검증한다. 하나라도 실패하면 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..6212340fb 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 @@ -29,6 +29,9 @@ MBA-105 구현 baseline, 운영 기본값, permission helper output, active vers | 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 Administration Application | Restore, exact reorder, bundle revoke와 multi-Collection bulk mutation의 authorization·lock·audit·transaction 순서를 조율한다 | Endpoint나 Client가 lifecycle, last-manage, self-escalation 또는 partial success를 판단하지 않는다 | +| Collection Management PostgreSQL Adapter | Organization-scoped row projection, Collection-first `FOR UPDATE`, membership lock, subject UUID keyset page와 permission bulk projection을 제공한다 | Raw principal을 projection하지 않고 target별 query N+1이나 offset pagination을 만들지 않는다 | +| 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..41913d62b 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는 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로 닫는다. Item 조회, link와 reorder 성공 응답은 최신 전체 ordered projection과 revision을 반환한다. 초기 관리 UI는 500개 이하 Collection만 reorder하고 초과 시 고정 safe reason으로 비활성화한다. +- 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..e02bbf3bd 100644 --- a/docs/features/knowledge/test_cases.md +++ b/docs/features/knowledge/test_cases.md @@ -410,6 +410,24 @@ 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를 만들지 않는다. +- Item 0개와 1개는 stable revision을 만들 수 있고 500개는 reorder 가능하다. 501개부터 `reorder_supported=false`, `safe_reason_code=item_reorder_limit_exceeded`이며 mutation을 허용하지 않는다. Token은 권한이나 item 조회 capability로 사용되지 않는다. +- 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한다. +- 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가 있으면 허용한다. +- 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를 반환하지 않는다. 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를 표시한다. From 5394c6024a8964ff0af9e05670581bf9f5f8beab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Tue, 14 Jul 2026 21:58:54 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(knowledge):=20KC=20=EC=9A=B4=EC=98=81?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EA=B2=BD=EA=B3=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../audit/knowledge_collection_operations.py | 34 + .../db/knowledge_collection_operations.py | 190 ++++ apps/gateway/api/v1/endpoints/knowledge.py | 228 ++++- .../collection_operations.py | 428 +++++++++ .../delegation_subjects.py | 104 +++ .../composition/knowledge_administration.py | 21 + .../services/knowledge_collection_service.py | 814 +++++++++++++++--- apps/shared/schemas/knowledge.py | 44 +- .../services/knowledge_permission_service.py | 31 +- 9 files changed, 1737 insertions(+), 157 deletions(-) create mode 100644 apps/gateway/adapters/audit/knowledge_collection_operations.py create mode 100644 apps/gateway/adapters/db/knowledge_collection_operations.py create mode 100644 apps/gateway/application/knowledge_administration/collection_operations.py create mode 100644 apps/gateway/application/knowledge_administration/delegation_subjects.py 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..2b92f0fbe --- /dev/null +++ b/apps/gateway/application/knowledge_administration/collection_operations.py @@ -0,0 +1,428 @@ +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) + 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_service.py b/apps/gateway/services/knowledge_collection_service.py index bcf0eefd2..26cfad722 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,7 +62,6 @@ 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, ) @@ -138,6 +153,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 +259,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 +313,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 +337,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 +351,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 +365,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 +382,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 +497,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 +630,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 +640,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 +654,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 +672,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 +730,211 @@ 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) + + if not changed_collection_ids: + self.db.rollback() + else: + try: + 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 + + changed_count = len(changed_collection_ids) + return KnowledgeCollectionPermissionBulkBundleResponse( + operation=request.operation, + subject_type=request.subject_type, + role_bundle=request.role_bundle, + target_count_bucket=bucket_count(len(collections)), + changed_count_bucket=bucket_count(changed_count), + unchanged_count_bucket=bucket_count(len(collections) - changed_count), + ) + 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 +945,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 +959,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 +991,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 +1011,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 +1023,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 +1059,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 +1075,7 @@ def _grant_team_permission( TeamKnowledgeCollectionPermission.permission_action == request.permission_action, ) + .with_for_update() .first() ) if row is not None: @@ -815,18 +1096,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 +1125,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 +1138,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 +1149,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 +1202,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 +1274,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 +1289,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 +1368,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 +1390,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 +1428,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 +1441,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 +1631,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 +1703,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/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( From cf7b78feaf6adbe931913878a993e84ccc3cf28f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Tue, 14 Jul 2026 21:59:05 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat(client):=20KC=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=99=94=EB=A9=B4=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/knowledge/api/knowledgeApi.ts | 70 +- .../knowledge-collection-manager-panels.tsx | 616 +++++++++++++++--- .../knowledge-collection-manager.tsx | 545 +++++++++++++--- .../app/features/knowledge/types/Knowledge.ts | 16 +- 4 files changed, 1043 insertions(+), 204 deletions(-) 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.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 { From 771547bdedc711679a3c7eb053d78202fee17c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Tue, 14 Jul 2026 21:59:14 +0900 Subject: [PATCH 4/7] =?UTF-8?q?test(knowledge):=20KC=20=EC=9A=B4=EC=98=81?= =?UTF-8?q?=20=EA=B2=BD=EA=B3=84=EC=99=80=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../knowledge/api/knowledgeApi.test.ts | 189 +++- .../knowledge-collection-manager.test.tsx | 298 +++++- .../api/test_knowledge_collection_api.py | 139 ++- .../knowledge_administration/__init__.py | 1 + .../test_collection_operations.py | 398 +++++++ .../test_delegation_subjects.py | 70 ++ ...nowledge_collection_operations_postgres.py | 967 ++++++++++++++++++ .../test_knowledge_collection_service.py | 136 ++- .../test_knowledge_permission_runtime_bulk.py | 37 + 9 files changed, 2172 insertions(+), 63 deletions(-) create mode 100644 apps/gateway/tests/application/knowledge_administration/__init__.py create mode 100644 apps/gateway/tests/application/knowledge_administration/test_collection_operations.py create mode 100644 apps/gateway/tests/application/knowledge_administration/test_delegation_subjects.py create mode 100644 apps/gateway/tests/integration/test_knowledge_collection_operations_postgres.py 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/components/knowledge-collection-manager.test.tsx b/apps/client/app/features/knowledge/components/knowledge-collection-manager.test.tsx index 4edb04f9d..e69da63b1 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(); @@ -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/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..b8d396bea --- /dev/null +++ b/apps/gateway/tests/application/knowledge_administration/test_collection_operations.py @@ -0,0 +1,398 @@ +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 = [] + + 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): + 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 + + +@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/services/test_knowledge_collection_service.py b/apps/gateway/tests/services/test_knowledge_collection_service.py index 0f95abde5..d24944d5a 100644 --- a/apps/gateway/tests/services/test_knowledge_collection_service.py +++ b/apps/gateway/tests/services/test_knowledge_collection_service.py @@ -52,6 +52,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 +100,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 +292,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 +326,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 +385,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 +493,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 +514,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 +538,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 +565,65 @@ 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" + + +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 +646,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/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() From f6a47e57c9013f63c7ba4455447f35ea80d7d631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Tue, 14 Jul 2026 21:59:42 +0900 Subject: [PATCH 5/7] =?UTF-8?q?docs:=20KC=20=EC=9A=B4=EC=98=81=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=EC=9D=84=20=EA=B5=AC=ED=98=84=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EC=99=80=20=EC=A0=95=ED=95=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...lection-operational-management-boundary.md | 44 ++++++++++++++----- docs/features/knowledge/api_spec.md | 6 +-- docs/features/knowledge/component_spec.md | 6 +-- docs/features/knowledge/requirements.md | 2 +- docs/features/knowledge/test_cases.md | 6 ++- 5 files changed, 44 insertions(+), 20 deletions(-) diff --git a/docs/decisions/ADR-0044-knowledge-collection-operational-management-boundary.md b/docs/decisions/ADR-0044-knowledge-collection-operational-management-boundary.md index 3a4f418a7..159ef41ae 100644 --- a/docs/decisions/ADR-0044-knowledge-collection-operational-management-boundary.md +++ b/docs/decisions/ADR-0044-knowledge-collection-operational-management-boundary.md @@ -62,10 +62,20 @@ Collection/KB content-plane 분리는 유지하면서 lifecycle, order와 permis - Link, unlink, reorder와 visibility mutation은 같은 Collection-first lock protocol을 사용한다. - GET, link와 reorder 성공 response는 최신 전체 ordered projection과 revision을 - 반환하고 unlink는 `204` 뒤 재조회를 유지한다. + 반환하고 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으로 비활성화한다. -- Current order와 같은 request는 no-op이며 새 audit를 만들지 않는다. +- 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 @@ -96,14 +106,23 @@ Collection/KB content-plane 분리는 유지하면서 lifecycle, order와 permis 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 -- 신규 flow는 FastAPI endpoint → Knowledge administration application use case → - repository/audit port → SQLAlchemy/audit adapter → Unit of Work 순서를 따른다. +- 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을 @@ -127,6 +146,7 @@ bundle을 role provenance로 잘못 해석하는 문제를 피하고, all-or-not - 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 @@ -144,8 +164,8 @@ bundle을 role provenance로 잘못 해석하는 문제를 피하고, all-or-not - `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_management.py` -- `apps/gateway/adapters/audit/knowledge_collection_management.py` +- `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` @@ -154,15 +174,15 @@ bundle을 role provenance로 잘못 해석하는 문제를 피하고, all-or-not ## Implementation State -이 ADR은 MBA-264의 승인된 목표 계약이다. ADR을 추가하는 문서 커밋 시점에는 기존 -archive, partial reorder, unbounded subject list와 action-row revoke baseline이 남아 -있다. 구현 완료 여부는 코드와 관련 자동화 검증 결과로 판정하며 문서 자체가 완료를 -의미하지 않는다. +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 -- 실제 PostgreSQL에서 organization predicate, `FOR UPDATE`, concurrent - restore/reorder/bulk와 audit rollback을 fake condition 없이 검증한다. +- 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, diff --git a/docs/features/knowledge/api_spec.md b/docs/features/knowledge/api_spec.md index 5fe2544f3..436efd4df 100644 --- a/docs/features/knowledge/api_spec.md +++ b/docs/features/knowledge/api_spec.md @@ -493,9 +493,9 @@ List의 `lifecycle_state` query는 `active`, `archived`, `deleted` 중 하나이 | 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 후보만 반환 | -`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 가능성을 의미하지 않는다. 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는 현재 전체 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가 아니다. +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 @@ -513,7 +513,7 @@ Reorder request는 현재 전체 item을 `{item_id, rank}`로 보내고 `expecte 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가 바뀌면 거부한다. -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 전에 검증한다. 하나라도 실패하면 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를 반복하지 않는다. +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 6212340fb..1cf225b0f 100644 --- a/docs/features/knowledge/component_spec.md +++ b/docs/features/knowledge/component_spec.md @@ -28,9 +28,9 @@ 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 Administration Application | Restore, exact reorder, bundle revoke와 multi-Collection bulk mutation의 authorization·lock·audit·transaction 순서를 조율한다 | Endpoint나 Client가 lifecycle, last-manage, self-escalation 또는 partial success를 판단하지 않는다 | -| Collection Management PostgreSQL Adapter | Organization-scoped row projection, Collection-first `FOR UPDATE`, membership lock, subject UUID keyset page와 permission bulk projection을 제공한다 | Raw principal을 projection하지 않고 target별 query N+1이나 offset pagination을 만들지 않는다 | +| 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로만 제공한다 | diff --git a/docs/features/knowledge/requirements.md b/docs/features/knowledge/requirements.md index 41913d62b..e575cee5e 100644 --- a/docs/features/knowledge/requirements.md +++ b/docs/features/knowledge/requirements.md @@ -143,7 +143,7 @@ Workflow canvas에는 독립형 RAG 실행 노드를 도입하지 않는다. Kno - 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는 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로 닫는다. Item 조회, link와 reorder 성공 응답은 최신 전체 ordered projection과 revision을 반환한다. 초기 관리 UI는 500개 이하 Collection만 reorder하고 초과 시 고정 safe reason으로 비활성화한다. +- 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만 포함한다. diff --git a/docs/features/knowledge/test_cases.md b/docs/features/knowledge/test_cases.md index e02bbf3bd..a5495146b 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 @@ -415,7 +415,10 @@ Verified Against: `origin/dev @ 32fb602f` - 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를 만들지 않는다. - 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다. @@ -423,6 +426,7 @@ Verified Against: `origin/dev @ 32fb602f` - 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를 반환하지 않는다. 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을 허용하지 않는다. From 7f49f2b78cd8ba755ffb8f75569129f820554b5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Tue, 14 Jul 2026 23:45:34 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20PR=20=ED=92=88=EC=A7=88=20=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EA=B2=BD=EA=B3=84=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../knowledge-collection-manager.test.tsx | 2 +- .../test_knowledge_ingestion_status_sync_db.py | 6 +++++- .../test_knowledge_safe_metadata_permission_db.py | 6 +++++- ...wledge_runtime_snapshot_disposable_postgres.py | 15 ++++++++++++--- ..._knowledge_safe_metadata_migration_postgres.py | 10 +++++++--- 5 files changed, 30 insertions(+), 9 deletions(-) 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 e69da63b1..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 @@ -159,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, 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/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}"')) From 6697219d9c953709036c730c85f5ab3101929c91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Wed, 15 Jul 2026 00:49:27 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20KC=20=EC=9D=BC=EA=B4=84=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=9D=91=EB=8B=B5=EA=B3=BC=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=9D=80=EB=8B=89=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../collection_operations.py | 3 + .../services/knowledge_collection_policy.py | 16 ++- .../services/knowledge_collection_service.py | 50 +++++---- .../test_collection_operations.py | 21 ++++ .../test_knowledge_collection_service.py | 105 ++++++++++++++++++ docs/features/knowledge/test_cases.md | 4 +- 6 files changed, 173 insertions(+), 26 deletions(-) diff --git a/apps/gateway/application/knowledge_administration/collection_operations.py b/apps/gateway/application/knowledge_administration/collection_operations.py index 2b92f0fbe..ebfc05e4e 100644 --- a/apps/gateway/application/knowledge_administration/collection_operations.py +++ b/apps/gateway/application/knowledge_administration/collection_operations.py @@ -261,6 +261,9 @@ 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 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 26cfad722..ab9dd13e7 100644 --- a/apps/gateway/services/knowledge_collection_service.py +++ b/apps/gateway/services/knowledge_collection_service.py @@ -67,6 +67,7 @@ ) from apps.gateway.services.knowledge_collection_policy import ( bucket_count, + bulk_permission_count_bucket, collection_visibility, normalize_optional_text, normalize_required_text, @@ -890,10 +891,21 @@ def mutate_permission_bundle_bulk( changed_collection_ids.add(row.knowledge_collection_id) self.db.delete(row) - if not changed_collection_ids: - self.db.rollback() - else: - try: + 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 @@ -912,26 +924,18 @@ def mutate_permission_bundle_bulk( }, ) 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 + except IntegrityError as exc: + self.db.rollback() + raise KnowledgeCollectionServiceError( + 409, + "conflict", + "Knowledge Collection permissions changed concurrently.", + ) from exc + except Exception: + self.db.rollback() + raise - changed_count = len(changed_collection_ids) - return KnowledgeCollectionPermissionBulkBundleResponse( - operation=request.operation, - subject_type=request.subject_type, - role_bundle=request.role_bundle, - target_count_bucket=bucket_count(len(collections)), - changed_count_bucket=bucket_count(changed_count), - unchanged_count_bucket=bucket_count(len(collections) - changed_count), - ) + return response def revoke_permission(self, collection_id: uuid.UUID, permission_id: uuid.UUID) -> None: collection = self._locked_collection_or_hidden(collection_id) diff --git a/apps/gateway/tests/application/knowledge_administration/test_collection_operations.py b/apps/gateway/tests/application/knowledge_administration/test_collection_operations.py index b8d396bea..0d561cda3 100644 --- a/apps/gateway/tests/application/knowledge_administration/test_collection_operations.py +++ b/apps/gateway/tests/application/knowledge_administration/test_collection_operations.py @@ -47,6 +47,7 @@ def __init__(self, collection, items=(), *, has_source_managed_items=False): 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) @@ -58,6 +59,7 @@ 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): @@ -297,6 +299,25 @@ def test_reorder_rejects_stale_revision_without_rank_change(): 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", [ diff --git a/apps/gateway/tests/services/test_knowledge_collection_service.py b/apps/gateway/tests/services/test_knowledge_collection_service.py index d24944d5a..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 @@ -591,6 +624,78 @@ def test_bulk_authority_prefers_resource_manage_over_domain_delegate(monkeypatch 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() diff --git a/docs/features/knowledge/test_cases.md b/docs/features/knowledge/test_cases.md index a5495146b..814fa959f 100644 --- a/docs/features/knowledge/test_cases.md +++ b/docs/features/knowledge/test_cases.md @@ -413,7 +413,7 @@ Verified Against: `feature/mba-264 @ 8832d23b` - 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를 만들지 않는다. +- 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의 권한 오류로 실패 응답을 반환하면 테스트 실패다. @@ -428,7 +428,7 @@ Verified Against: `feature/mba-264 @ 8832d23b` - 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를 반환하지 않는다. 1, 2, 50개 grant/revoke와 retry는 duplicate permission row 없이 deterministic해야 한다. +- 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 변경을 추가하지 않는다.