@team-semicolon/semicolony-cli 4.18.107 → 4.18.108
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
-- 208_meeting_projection_classification_repair.sql
|
|
2
|
+
--
|
|
3
|
+
-- Close a live confidentiality exposure, then make it unrepeatable.
|
|
4
|
+
--
|
|
5
|
+
-- `knowledge_base_hide_restricted` hides a row from sc_app, sc_fleet_board,
|
|
6
|
+
-- semo_ops_bot and semo_ops_hive_cron when
|
|
7
|
+
-- `metadata -> 'classification' ->> 'sensitivity' = 'restricted'`. Measured
|
|
8
|
+
-- 2026-08-18: of 204 `key='meeting'` projections, **74 carried no
|
|
9
|
+
-- `classification` key at all**, and every one of those belongs to a meeting
|
|
10
|
+
-- whose `meetings.visibility` is `restricted`.
|
|
11
|
+
--
|
|
12
|
+
-- The policy therefore did not hide them. `sc_app` could read all 204 — client
|
|
13
|
+
-- pricing, revenue-share terms, contract start dates, design reviews — and every
|
|
14
|
+
-- bot that reads the Knowledge Fabric runs at or below that level. The Slack
|
|
15
|
+
-- actors answer from `kb_search`, so this was reachable in ordinary use.
|
|
16
|
+
--
|
|
17
|
+
-- `record_meeting` is not the cause: called today it still emits
|
|
18
|
+
-- `classification.sensitivity`, verified in a rolled-back transaction. The 74
|
|
19
|
+
-- rows were written or later rewritten by a path that rebuilt `metadata` without
|
|
20
|
+
-- that key. Rather than find and fix every such writer, this migration derives
|
|
21
|
+
-- the value from the table that owns it and then stops the projection from ever
|
|
22
|
+
-- being written without it again.
|
|
23
|
+
--
|
|
24
|
+
-- Part A repairs the existing rows. Part B installs the guard.
|
|
25
|
+
--
|
|
26
|
+
-- The runner owns the transaction boundary, so no BEGIN/COMMIT here, and it
|
|
27
|
+
-- retargets the `semicolony.` qualifier to the active schema.
|
|
28
|
+
|
|
29
|
+
-- ---------------------------------------------------------------- Part A
|
|
30
|
+
-- Derive the missing classification from the meeting the projection names.
|
|
31
|
+
-- `meetings.visibility` is the record of authority; the projection only mirrors
|
|
32
|
+
-- it. All 74 carry a resolvable `meeting_id` (measured: 0 unresolvable).
|
|
33
|
+
|
|
34
|
+
UPDATE semicolony.knowledge_base kb
|
|
35
|
+
SET metadata = kb.metadata || jsonb_build_object(
|
|
36
|
+
'classification',
|
|
37
|
+
jsonb_build_object(
|
|
38
|
+
'sensitivity',
|
|
39
|
+
CASE WHEN m.visibility = 'restricted' THEN 'restricted' ELSE 'internal' END
|
|
40
|
+
)
|
|
41
|
+
),
|
|
42
|
+
updated_at = now()
|
|
43
|
+
FROM semicolony.meetings m
|
|
44
|
+
WHERE kb.key = 'meeting'
|
|
45
|
+
AND m.meeting_id::text = kb.metadata ->> 'meeting_id'
|
|
46
|
+
AND kb.metadata -> 'classification' ->> 'sensitivity' IS DISTINCT FROM
|
|
47
|
+
(CASE WHEN m.visibility = 'restricted' THEN 'restricted' ELSE 'internal' END);
|
|
48
|
+
|
|
49
|
+
DO $$
|
|
50
|
+
DECLARE v_unclassified int;
|
|
51
|
+
BEGIN
|
|
52
|
+
SELECT count(*) INTO v_unclassified
|
|
53
|
+
FROM semicolony.knowledge_base
|
|
54
|
+
WHERE key = 'meeting'
|
|
55
|
+
AND metadata -> 'classification' ->> 'sensitivity' IS NULL;
|
|
56
|
+
IF v_unclassified > 0 THEN
|
|
57
|
+
RAISE EXCEPTION 'still % meeting projection(s) without a classification', v_unclassified;
|
|
58
|
+
END IF;
|
|
59
|
+
END $$;
|
|
60
|
+
|
|
61
|
+
-- ---------------------------------------------------------------- Part B
|
|
62
|
+
-- A meeting projection may not exist without a classification.
|
|
63
|
+
--
|
|
64
|
+
-- Enforced in the database rather than in each writer, because the writer is
|
|
65
|
+
-- exactly what went wrong: the capability emits the key correctly and something
|
|
66
|
+
-- else still produced 74 rows without it. A trigger holds regardless of who
|
|
67
|
+
-- writes, including a direct UPDATE by an operator script.
|
|
68
|
+
--
|
|
69
|
+
-- It derives rather than rejects. Refusing the write would turn a metadata slip
|
|
70
|
+
-- into a lost meeting record, and losing the record is the failure this whole
|
|
71
|
+
-- system was built to stop. Deriving keeps the record and closes the hole.
|
|
72
|
+
--
|
|
73
|
+
-- Fails closed: a projection naming no resolvable meeting gets `restricted`.
|
|
74
|
+
|
|
75
|
+
CREATE OR REPLACE FUNCTION semicolony.enforce_meeting_projection_classification()
|
|
76
|
+
RETURNS trigger
|
|
77
|
+
LANGUAGE plpgsql
|
|
78
|
+
SECURITY DEFINER
|
|
79
|
+
SET search_path = pg_catalog
|
|
80
|
+
AS $function$
|
|
81
|
+
DECLARE
|
|
82
|
+
v_visibility text;
|
|
83
|
+
v_sensitivity text;
|
|
84
|
+
BEGIN
|
|
85
|
+
IF NEW.key IS DISTINCT FROM 'meeting' THEN
|
|
86
|
+
RETURN NEW;
|
|
87
|
+
END IF;
|
|
88
|
+
IF NEW.metadata -> 'classification' ->> 'sensitivity' IS NOT NULL THEN
|
|
89
|
+
RETURN NEW;
|
|
90
|
+
END IF;
|
|
91
|
+
|
|
92
|
+
SELECT m.visibility INTO v_visibility
|
|
93
|
+
FROM semicolony.meetings m
|
|
94
|
+
WHERE m.meeting_id::text = NEW.metadata ->> 'meeting_id';
|
|
95
|
+
|
|
96
|
+
-- Unknown provenance is treated as confidential, not as internal.
|
|
97
|
+
v_sensitivity := CASE
|
|
98
|
+
WHEN v_visibility IS NULL THEN 'restricted'
|
|
99
|
+
WHEN v_visibility = 'restricted' THEN 'restricted'
|
|
100
|
+
ELSE 'internal'
|
|
101
|
+
END;
|
|
102
|
+
|
|
103
|
+
NEW.metadata := COALESCE(NEW.metadata, '{}'::jsonb) || jsonb_build_object(
|
|
104
|
+
'classification', jsonb_build_object('sensitivity', v_sensitivity)
|
|
105
|
+
);
|
|
106
|
+
RETURN NEW;
|
|
107
|
+
END;
|
|
108
|
+
$function$;
|
|
109
|
+
|
|
110
|
+
DROP TRIGGER IF EXISTS meeting_projection_classification ON semicolony.knowledge_base;
|
|
111
|
+
CREATE TRIGGER meeting_projection_classification
|
|
112
|
+
BEFORE INSERT OR UPDATE ON semicolony.knowledge_base
|
|
113
|
+
FOR EACH ROW
|
|
114
|
+
EXECUTE FUNCTION semicolony.enforce_meeting_projection_classification();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
-- 209_action_item_project_any_ontology_domain.sql
|
|
2
|
+
--
|
|
3
|
+
-- 액션 아이템의 project_domain 을 ontology 의 모든 엔티티 종류에 개방한다.
|
|
4
|
+
--
|
|
5
|
+
-- 203_action_item_redesign_cutover.sql 은 project_entity 를
|
|
6
|
+
-- project_entity text GENERATED ALWAYS AS ('service') STORED
|
|
7
|
+
-- 로 고정하고 (project_domain, project_entity) 복합 FK 를
|
|
8
|
+
-- ontology(domain, entity_type) 로 걸었다. 상수 컬럼과 복합 FK 의 조합은 곧
|
|
9
|
+
-- "project_domain 은 entity_type='service' 인 도메인만 받는다" 라는 제약이다.
|
|
10
|
+
--
|
|
11
|
+
-- 이 제약은 의도한 것보다 넓게 막고 있었다:
|
|
12
|
+
--
|
|
13
|
+
-- * 팀 차원의 일(결제·계약 행정 등)은 organization 도메인 'semicolon' 에
|
|
14
|
+
-- 귀속되는데 등록 자체가 23503 으로 거부된다. 2026-08-18 실측: 토스페이먼츠
|
|
15
|
+
-- 서류 보완 3건이 이 이유로 등록되지 못했다.
|
|
16
|
+
-- * meeting-generate 는 meeting.target_domain 이 organization 이거나 person
|
|
17
|
+
-- 이면 그 회의의 액션 아이템 전량을 건너뛰고 경고만 남긴다
|
|
18
|
+
-- (packages/semicolony-dashboard/lib/core/meeting-generate.ts).
|
|
19
|
+
-- 'semicolon' 을 대상으로 한 회의는 액션 아이템을 한 건도 남기지 못한다.
|
|
20
|
+
--
|
|
21
|
+
-- ontology 에는 이미 domain 단일 UNIQUE 제약(ontology_domain_key)이 있고, 실제로
|
|
22
|
+
-- 두 개 이상의 entity_type 을 갖는 domain 은 하나도 없다. 즉 domain 하나로 행이
|
|
23
|
+
-- 유일하게 정해지므로, 복합 FK 는 "도메인이 실재하는가" 검증에 아무 것도 보태지
|
|
24
|
+
-- 않고 오직 종류를 'service' 로 고정하는 역할만 한다. 단일 컬럼 FK 로 바꾸면
|
|
25
|
+
-- 실재 검증은 그대로 유지되고 종류 제한만 사라진다.
|
|
26
|
+
--
|
|
27
|
+
-- project_entity 컬럼은 지우지 않는다 — 읽는 쪽이 있고(dashboard ActionItemRow),
|
|
28
|
+
-- "이 도메인이 무엇인가" 는 조회할 때마다 조인하는 것보다 행에 두는 편이 싸다.
|
|
29
|
+
-- 다만 상수가 아니라 ontology 실측값을 트리거로 채운다. 상수였을 때는 이 컬럼이
|
|
30
|
+
-- 정보를 담지 않았고(모든 행이 'service'), 제약을 거는 도구로만 쓰였다.
|
|
31
|
+
--
|
|
32
|
+
-- 게이트웨이의 23503 → 'project_domain is not a registered project' 매핑은 그대로
|
|
33
|
+
-- 유효하다. 등록되지 않은 도메인은 여전히 단일 컬럼 FK 에서 23503 으로 막힌다.
|
|
34
|
+
|
|
35
|
+
BEGIN;
|
|
36
|
+
|
|
37
|
+
-- 1. 종류를 고정하던 복합 FK 를 걷어낸다.
|
|
38
|
+
ALTER TABLE semicolony.action_items
|
|
39
|
+
DROP CONSTRAINT action_items_project_domain_project_entity_fkey;
|
|
40
|
+
|
|
41
|
+
-- 2. 생성 표현식('service' 상수)을 떼어 평범한 컬럼으로 만든다. PG 14+.
|
|
42
|
+
ALTER TABLE semicolony.action_items
|
|
43
|
+
ALTER COLUMN project_entity DROP EXPRESSION;
|
|
44
|
+
|
|
45
|
+
-- 3. 도메인 실재 검증은 단일 컬럼 FK 로 유지한다.
|
|
46
|
+
ALTER TABLE semicolony.action_items
|
|
47
|
+
ADD CONSTRAINT action_items_project_domain_fkey
|
|
48
|
+
FOREIGN KEY (project_domain) REFERENCES semicolony.ontology(domain);
|
|
49
|
+
|
|
50
|
+
-- 4. project_entity 는 ontology 실측값을 따라간다.
|
|
51
|
+
-- 도메인이 ontology 에 없으면 NULL 이 남고, 3번 FK 가 그 행을 23503 으로 막는다.
|
|
52
|
+
CREATE OR REPLACE FUNCTION semicolony.action_item_set_project_entity()
|
|
53
|
+
RETURNS trigger
|
|
54
|
+
LANGUAGE plpgsql
|
|
55
|
+
AS $$
|
|
56
|
+
BEGIN
|
|
57
|
+
SELECT o.entity_type
|
|
58
|
+
INTO NEW.project_entity
|
|
59
|
+
FROM semicolony.ontology o
|
|
60
|
+
WHERE o.domain = NEW.project_domain;
|
|
61
|
+
RETURN NEW;
|
|
62
|
+
END;
|
|
63
|
+
$$;
|
|
64
|
+
|
|
65
|
+
DROP TRIGGER IF EXISTS action_items_set_project_entity ON semicolony.action_items;
|
|
66
|
+
CREATE TRIGGER action_items_set_project_entity
|
|
67
|
+
BEFORE INSERT OR UPDATE OF project_domain ON semicolony.action_items
|
|
68
|
+
FOR EACH ROW
|
|
69
|
+
EXECUTE FUNCTION semicolony.action_item_set_project_entity();
|
|
70
|
+
|
|
71
|
+
-- 5. 기존 행 보정. 지금은 전부 'service' 라 실질 no-op 이지만, 컬럼의 의미가
|
|
72
|
+
-- "상수" 에서 "ontology 실측값" 으로 바뀌었으므로 값을 그 의미에 맞춘다.
|
|
73
|
+
UPDATE semicolony.action_items a
|
|
74
|
+
SET project_entity = o.entity_type
|
|
75
|
+
FROM semicolony.ontology o
|
|
76
|
+
WHERE o.domain = a.project_domain
|
|
77
|
+
AND a.project_entity IS DISTINCT FROM o.entity_type;
|
|
78
|
+
|
|
79
|
+
COMMIT;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
-- 어드민 ops 사용자가 `restricted` 지식을 읽을 수 있게 한다.
|
|
2
|
+
--
|
|
3
|
+
-- 문제. 대시보드는 사용자와 무관하게 DB 롤 하나(sc_fleet_board)로 접속하고,
|
|
4
|
+
-- 어드민 판정은 Supabase `user_profiles.role='admin'` 이라는 앱 레이어 값이다
|
|
5
|
+
-- (lib/ops/auth.ts evaluateOpsAccess). RLS 는 그 값을 볼 수 없으므로 "어드민만"
|
|
6
|
+
-- 이라는 조건을 정책 술어로 쓸 수 없다. 정책에서 sc_fleet_board 를 빼면 승인된
|
|
7
|
+
-- ops 멤버 전원(2026-08-19 실측 33명)이 고객 단가·계약 조건을 읽게 된다.
|
|
8
|
+
--
|
|
9
|
+
-- 방법. 읽기 표면이 같고 등급 제한만 없는 롤을 하나 더 만들고, 대시보드가
|
|
10
|
+
-- `isAdmin` 일 때만 그 롤의 풀을 쓴다. 판단은 앱이 하되 경계는 DB 가 강제한다 —
|
|
11
|
+
-- 이미 sc_app / sc_fleet_board / sc_ops_records 로 나눠 온 것과 같은 방식이다.
|
|
12
|
+
--
|
|
13
|
+
-- 왜 멤버십 상속이 안전한가. RLS 정책 매칭은 `pg_has_role(current_user, ...,
|
|
14
|
+
-- 'MEMBER')` 이라 sc_fleet_board_admin 은 knowledge_base_hide_restricted 에도
|
|
15
|
+
-- 매칭된다. 그런데 knowledge_base 의 정책은 전부 PERMISSIVE 라 매칭된 정책이
|
|
16
|
+
-- OR 로 합쳐지고, 아래 USING (true) 가 제한을 덮는다(2026-08-19 pg_policy
|
|
17
|
+
-- polpermissive=true 실측). 반대 방향은 성립하지 않는다 — sc_fleet_board 는
|
|
18
|
+
-- 이 새 정책에 매칭되지 않으므로 제한이 그대로 남는다. 훗날 누군가
|
|
19
|
+
-- hide_restricted 를 RESTRICTIVE 로 바꾸면 어드민이 못 읽게 되지 어드민이 아닌
|
|
20
|
+
-- 쪽이 더 읽게 되지는 않는다 — 안전한 방향으로 깨진다.
|
|
21
|
+
--
|
|
22
|
+
-- 비밀번호는 여기서 정하지 않는다. 마이그레이션은 git 에 남고 자격증명은 남으면
|
|
23
|
+
-- 안 된다. 운영자가 별도로 ALTER ROLE ... PASSWORD 로 부여한다.
|
|
24
|
+
DO $$
|
|
25
|
+
BEGIN
|
|
26
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'sc_fleet_board_admin') THEN
|
|
27
|
+
CREATE ROLE sc_fleet_board_admin LOGIN;
|
|
28
|
+
END IF;
|
|
29
|
+
END $$;
|
|
30
|
+
|
|
31
|
+
-- 읽기 표면은 sc_fleet_board 와 정확히 같다. 목록을 복제하면 한쪽만 늘어나
|
|
32
|
+
-- 갈라지므로 멤버십으로 상속시킨다.
|
|
33
|
+
GRANT sc_fleet_board TO sc_fleet_board_admin;
|
|
34
|
+
|
|
35
|
+
DROP POLICY IF EXISTS knowledge_base_ops_admin_read ON semo_ops.knowledge_base;
|
|
36
|
+
CREATE POLICY knowledge_base_ops_admin_read ON semo_ops.knowledge_base
|
|
37
|
+
FOR SELECT TO sc_fleet_board_admin USING (true);
|
|
38
|
+
|
|
39
|
+
-- 이 롤은 읽기 전용이다. 상속으로 들어온 worker_fleet_audits INSERT 를 제외하면
|
|
40
|
+
-- 어떤 쓰기 경로도 갖지 않으며, 등급 해제는 SELECT 한 명령에만 적용된다.
|
|
41
|
+
DO $$
|
|
42
|
+
DECLARE v_bad text;
|
|
43
|
+
BEGIN
|
|
44
|
+
SELECT string_agg(DISTINCT privilege_type, ',') INTO v_bad
|
|
45
|
+
FROM information_schema.role_table_grants
|
|
46
|
+
WHERE grantee = 'sc_fleet_board_admin'
|
|
47
|
+
AND table_schema = 'semo_ops' AND table_name = 'knowledge_base'
|
|
48
|
+
AND privilege_type IN ('INSERT','UPDATE','DELETE');
|
|
49
|
+
IF v_bad IS NOT NULL THEN
|
|
50
|
+
RAISE EXCEPTION 'sc_fleet_board_admin must stay read-only on knowledge_base (got %)', v_bad;
|
|
51
|
+
END IF;
|
|
52
|
+
END $$;
|