Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import { type Kysely } from 'kysely';
2import { type Writable } from 'type-fest';
3import { uid } from 'uid';
4
5import {
6 UserPermission,
7 type Invoker,
8} from '../../../models/types/permissioning.js';
9import {
10 CoopError,
11 ErrorType,
12 makeUnauthorizedError,
13 type ErrorInstanceData,
14} from '../../../utils/errors.js';
15import {
16 isUniqueViolationError,
17 type FixKyselyRowCorrelation,
18
19} from '../../../utils/kysely.js';
20import { removeUndefinedKeys } from '../../../utils/misc.js';
21import { type ModerationConfigServicePg } from '../dbTypes.js';
22import { type Policy } from '../index.js';
23import type { PolicyType } from '../types/policies.js';
24
25const policyDbSelection = [
26 'id',
27 'name',
28 'org_id as orgId',
29 'parent_id as parentId',
30 'created_at as createdAt',
31 'updated_at as updatedAt',
32 'policy_text as policyText',
33 'enforcement_guidelines as enforcementGuidelines',
34 'sys_period as sysPeriod',
35 'policy_type as policyType',
36 'semantic_version as semanticVersion',
37 'user_strike_count as userStrikeCount',
38 'apply_user_strike_count_config_to_children as applyUserStrikeCountConfigToChildren',
39 'penalty',
40] as const;
41
42const policyJoinDbSelection = [
43 'rap.rule_id as ruleId',
44 'p.id',
45 'p.name',
46 'p.org_id as orgId',
47 'p.parent_id as parentId',
48 'p.created_at as createdAt',
49 'p.updated_at as updatedAt',
50 'p.policy_text as policyText',
51 'p.enforcement_guidelines as enforcementGuidelines',
52 'p.sys_period as sysPeriod',
53 'p.policy_type as policyType',
54 'p.semantic_version as semanticVersion',
55 'p.user_strike_count as userStrikeCount',
56 'p.apply_user_strike_count_config_to_children as applyUserStrikeCountConfigToChildren',
57 'p.penalty',
58] as const;
59
60type PolicyDbResult = FixKyselyRowCorrelation<
61 ModerationConfigServicePg['public.policies'],
62 typeof policyDbSelection
63>;
64
65export default class PolicyOperations {
66 constructor(
67 private readonly pgQuery: Kysely<ModerationConfigServicePg>,
68 private readonly pgQueryReplica: Kysely<ModerationConfigServicePg>,
69 private readonly onDeletePolicyId: (opts: {
70 policyId: string;
71 orgId: string;
72 }) => Promise<void>,
73 ) {}
74
75 async getPolicies(opts: { orgId: string; readFromReplica?: boolean }) {
76 const { orgId, readFromReplica } = opts;
77 const pgQuery = this.#getPgQuery(readFromReplica);
78 const query = pgQuery
79 .selectFrom('public.policies')
80 .select(policyDbSelection)
81 .where('org_id', '=', orgId);
82 const results = (await query.execute()) as PolicyDbResult[];
83
84 return results.map((it) => this.#dbResultToPolicy(it));
85 }
86
87 async getPoliciesByIds(opts: {
88 orgId: string;
89 ids: readonly string[];
90 readFromReplica?: boolean;
91 }): Promise<Policy[]> {
92 const { orgId, ids, readFromReplica } = opts;
93 if (ids.length === 0) {
94 return [];
95 }
96 const pgQuery = this.#getPgQuery(readFromReplica ?? true);
97 const results = (await pgQuery
98 .selectFrom('public.policies')
99 .select(policyDbSelection)
100 .where('org_id', '=', orgId)
101 .where('id', 'in', [...ids])
102 .execute()) as PolicyDbResult[];
103
104 return results.map((it) => this.#dbResultToPolicy(it));
105 }
106
107 async getPoliciesByRuleIds(opts: {
108 ruleIds: readonly string[];
109 readFromReplica?: boolean;
110 }): Promise<Record<string, Policy[]>> {
111 const { ruleIds, readFromReplica } = opts;
112 if (ruleIds.length === 0) {
113 return {};
114 }
115 const pgQuery = this.#getPgQuery(readFromReplica ?? true);
116 type Row = PolicyDbResult & { ruleId: string };
117 const rows = (await pgQuery
118 .selectFrom('public.rules_and_policies as rap')
119 .innerJoin('public.policies as p', 'p.id', 'rap.policy_id')
120 .select(policyJoinDbSelection)
121 .where('rap.rule_id', 'in', [...ruleIds])
122 .execute()) as Row[];
123
124 const out: Record<string, Policy[]> = {};
125 for (const row of rows) {
126 const { ruleId, ...policyFields } = row;
127 const policy = this.#dbResultToPolicy(policyFields as PolicyDbResult);
128 (out[ruleId] ??= []).push(policy);
129 }
130 return out;
131 }
132
133 async getPolicy(opts: {
134 orgId: string;
135 policyId: string;
136 readFromReplica?: boolean;
137 }) {
138 const { orgId, policyId, readFromReplica } = opts;
139 const pgQuery = this.#getPgQuery(readFromReplica);
140 const query = pgQuery
141 .selectFrom('public.policies')
142 .select(policyDbSelection)
143 .where('org_id', '=', orgId)
144 .where('id', '=', policyId);
145 const result =
146 (await query.executeTakeFirst()) as PolicyDbResult;
147
148 return this.#dbResultToPolicy(result);
149 }
150
151 async createPolicy(opts: {
152 orgId: string;
153 policy: {
154 name: string;
155 parentId?: string | null;
156 policyText?: string | null;
157 enforcementGuidelines?: string | null;
158 policyType?: PolicyType | null;
159 };
160 invokedBy: Invoker;
161 }) {
162 const { orgId: org_id, policy, invokedBy } = opts;
163 const {
164 name,
165 parentId: parent_id,
166 policyText: policy_text,
167 enforcementGuidelines: enforcement_guidelines,
168 policyType: policy_type,
169 } = policy;
170 if (!invokedBy.permissions.includes(UserPermission.MANAGE_POLICIES)) {
171 throw makeUnauthorizedError(
172 'You do not have permission to create policies',
173 { shouldErrorSpan: true },
174 );
175 }
176
177 try {
178 const newPolicy = await this.pgQuery
179 .insertInto('public.policies')
180 .values({
181 id: uid(),
182 name,
183 org_id,
184 parent_id,
185 penalty: 'NONE',
186 policy_text,
187 enforcement_guidelines,
188 policy_type,
189 semantic_version: 1,
190 updated_at: new Date(),
191 })
192 .returning(policyDbSelection)
193 .executeTakeFirstOrThrow();
194
195 return this.#dbResultToPolicy(newPolicy);
196 } catch (e: unknown) {
197 throw isUniqueViolationError(e)
198 ? makePolicyNameExistsError({ shouldErrorSpan: true })
199 : e;
200 }
201 }
202
203 async updatePolicy(opts: {
204 orgId: string;
205 policy: {
206 id: string;
207 name?: string;
208 parentId?: string | null;
209 policyText?: string | null;
210 enforcementGuidelines?: string | null;
211 policyType?: PolicyType | null;
212 userStrikeCount?: number | null;
213 applyUserStrikeCountConfigToChildren?: boolean | null;
214 };
215 invokedBy: Invoker;
216 }) {
217 const { orgId, policy, invokedBy } = opts;
218 if (!invokedBy.permissions.includes(UserPermission.MANAGE_POLICIES)) {
219 throw makeUnauthorizedError(
220 'You do not have permission to update policies',
221 { shouldErrorSpan: true },
222 );
223 }
224
225 try {
226 const updatedPolicy = await this.pgQuery
227 .updateTable('public.policies')
228 .set(
229 removeUndefinedKeys({
230 name: policy.name,
231 parent_id: policy.parentId,
232 policy_text: policy.policyText,
233 enforcement_guidelines: policy.enforcementGuidelines,
234 policy_type: policy.policyType,
235 user_strike_count: policy.userStrikeCount ?? undefined,
236 apply_user_strike_count_config_to_children:
237 policy.applyUserStrikeCountConfigToChildren ?? undefined,
238 updated_at: new Date(),
239 }),
240 )
241 .where('org_id', '=', orgId)
242 .where('id', '=', policy.id)
243 .returning(policyDbSelection)
244 .executeTakeFirstOrThrow();
245
246 return this.#dbResultToPolicy(updatedPolicy);
247 } catch (e: unknown) {
248 throw isUniqueViolationError(e)
249 ? makePolicyNameExistsError({ shouldErrorSpan: true })
250 : e;
251 }
252 }
253
254 async deletePolicy(opts: {
255 orgId: string;
256 policyId: string;
257 invokedBy: Invoker;
258 }) {
259 const { orgId, policyId, invokedBy } = opts;
260 if (!invokedBy.permissions.includes(UserPermission.MANAGE_POLICIES)) {
261 throw makeUnauthorizedError(
262 'You do not have permission to delete policies',
263 { shouldErrorSpan: true },
264 );
265 }
266
267 const rowsDeleted = await this.pgQuery
268 .deleteFrom('public.policies')
269 .where('org_id', '=', orgId)
270 .where('id', '=', policyId)
271 .execute();
272
273 if (rowsDeleted.length === 1) {
274 // We don't need to wait for this to complete before returning.
275 // Additionally, if it fails, we don't want to throw an error because
276 // it's not critical that it succeeds, it's just a 'best effort' cleanup.
277 this.onDeletePolicyId({ policyId, orgId }).catch(() => {});
278 return true;
279 }
280
281 return false;
282 }
283
284 #dbResultToPolicy(it: PolicyDbResult) {
285 return it satisfies Writable<Policy> as Policy;
286 }
287
288 #getPgQuery(readFromReplica: boolean = false) {
289 return readFromReplica ? this.pgQueryReplica : this.pgQuery;
290 }
291}
292
293export type PolicyErrorType = 'PolicyNameExistsError';
294
295// TODO: throw this error on failed policy creation/update when appropriate.
296export const makePolicyNameExistsError = (data: ErrorInstanceData) =>
297 new CoopError({
298 status: 409,
299 type: [ErrorType.UniqueViolation],
300 title: 'A policy with that name already exists in this organization.',
301 name: 'PolicyNameExistsError',
302 ...data,
303 });