@objectstack/plugin-sharing 7.3.0 → 7.4.0

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,190 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { ObjectSchema, Field } from '@objectstack/spec/data';
4
+
5
+ /**
6
+ * sys_sharing_rule — Declarative record-sharing rule.
7
+ *
8
+ * Salesforce-style criteria-based sharing: "any record on object O that
9
+ * matches criteria C is granted access level A to recipient R". Rules
10
+ * are evaluated by `@objectstack/plugin-sharing` and materialise their
11
+ * grants as rows in `sys_record_share` with `source='rule'` and
12
+ * `source_id={rule.id}` so the evaluator can reconcile (delete + re-
13
+ * insert) on rule updates without touching manual grants.
14
+ *
15
+ * Evaluation triggers:
16
+ * - `afterInsert` / `afterUpdate` on the target object (per-record,
17
+ * incremental — the hot path).
18
+ * - REST `POST /sharing/rules/:id/evaluate` (admin-initiated
19
+ * bulk reconcile — used after rule edits).
20
+ *
21
+ * Criteria are stored as JSON (a normal `FilterCondition`) so the
22
+ * existing engine `find()` can do the matching natively. v1 supports
23
+ * simple `{field, op, value}` style filters; CEL predicates are a
24
+ * follow-up.
25
+ *
26
+ * @namespace sys
27
+ */
28
+ export const SysSharingRule = ObjectSchema.create({
29
+ name: 'sys_sharing_rule',
30
+ label: 'Sharing Rule',
31
+ pluralLabel: 'Sharing Rules',
32
+ icon: 'shield-check',
33
+ isSystem: true,
34
+ managedBy: 'config',
35
+ // Sharing rules can now be authored visually via the Studio criteria
36
+ // builder (apps/studio/src/components/SharingCriteriaBuilder.tsx).
37
+ // We still recommend `defineSharingRule({...})` for repo-controlled
38
+ // baselines, but admins can safely create/edit/delete from the UI.
39
+ userActions: { create: true, edit: true, delete: true, import: false },
40
+ description: 'Declarative sharing rule that auto-materialises sys_record_share grants. Authored via defineSharingRule() in code or the Studio criteria builder.',
41
+ displayNameField: 'name',
42
+ titleFormat: '{label}',
43
+ compactLayout: ['name', 'object_name', 'recipient_type', 'recipient_id', 'access_level', 'active'],
44
+
45
+ listViews: {
46
+ active: {
47
+ type: 'grid',
48
+ name: 'active',
49
+ label: 'Active',
50
+ data: { provider: 'object', object: 'sys_sharing_rule' },
51
+ columns: ['label', 'object_name', 'recipient_type', 'recipient_id', 'access_level', 'updated_at'],
52
+ filter: [{ field: 'active', operator: 'equals', value: true }],
53
+ sort: [{ field: 'object_name', order: 'asc' }, { field: 'label', order: 'asc' }],
54
+ pagination: { pageSize: 50 },
55
+ },
56
+ inactive: {
57
+ type: 'grid',
58
+ name: 'inactive',
59
+ label: 'Inactive',
60
+ data: { provider: 'object', object: 'sys_sharing_rule' },
61
+ columns: ['label', 'object_name', 'recipient_type', 'recipient_id', 'updated_at'],
62
+ filter: [{ field: 'active', operator: 'equals', value: false }],
63
+ sort: [{ field: 'label', order: 'asc' }],
64
+ pagination: { pageSize: 50 },
65
+ },
66
+ by_object: {
67
+ type: 'grid',
68
+ name: 'by_object',
69
+ label: 'By Object',
70
+ data: { provider: 'object', object: 'sys_sharing_rule' },
71
+ columns: ['object_name', 'label', 'recipient_type', 'access_level', 'active'],
72
+ sort: [{ field: 'object_name', order: 'asc' }, { field: 'label', order: 'asc' }],
73
+ grouping: { fields: [{ field: 'object_name', order: 'asc', collapsed: false }] },
74
+ pagination: { pageSize: 100 },
75
+ },
76
+ all_rules: {
77
+ type: 'grid',
78
+ name: 'all_rules',
79
+ label: 'All',
80
+ data: { provider: 'object', object: 'sys_sharing_rule' },
81
+ columns: ['label', 'object_name', 'recipient_type', 'recipient_id', 'access_level', 'active', 'updated_at'],
82
+ sort: [{ field: 'label', order: 'asc' }],
83
+ pagination: { pageSize: 50 },
84
+ },
85
+ },
86
+
87
+ fields: {
88
+ id: Field.text({ label: 'Rule ID', required: true, readonly: true, group: 'System' }),
89
+
90
+ organization_id: Field.lookup('sys_organization', {
91
+ label: 'Organization',
92
+ required: false,
93
+ group: 'System',
94
+ description: 'Tenant that owns this rule; null = global',
95
+ }),
96
+
97
+ name: Field.text({
98
+ label: 'Name',
99
+ required: true,
100
+ maxLength: 100,
101
+ description: 'Unique snake_case rule name',
102
+ group: 'Identity',
103
+ }),
104
+
105
+ label: Field.text({
106
+ label: 'Display Label',
107
+ required: true,
108
+ maxLength: 200,
109
+ group: 'Identity',
110
+ }),
111
+
112
+ description: Field.textarea({
113
+ label: 'Description',
114
+ required: false,
115
+ group: 'Identity',
116
+ }),
117
+
118
+ object_name: Field.text({
119
+ label: 'Object',
120
+ required: true,
121
+ maxLength: 100,
122
+ description: 'Short object name (e.g. opportunity, account)',
123
+ group: 'Target',
124
+ }),
125
+
126
+ criteria_json: Field.textarea({
127
+ label: 'Criteria (FilterCondition JSON)',
128
+ required: false,
129
+ description: 'JSON FilterCondition matched against records of object_name. Empty = match all.',
130
+ group: 'Target',
131
+ }),
132
+
133
+ recipient_type: Field.select(
134
+ ['user', 'team', 'department', 'role', 'queue'],
135
+ {
136
+ label: 'Recipient Type',
137
+ required: true,
138
+ defaultValue: 'department',
139
+ description: 'Kind of principal that receives access — expanded to user grants at evaluation time. `department` walks the parent_department_id tree; `team` is flat (better-auth).',
140
+ group: 'Recipient',
141
+ },
142
+ ),
143
+
144
+ recipient_id: Field.text({
145
+ label: 'Recipient',
146
+ required: true,
147
+ maxLength: 200,
148
+ description: 'department id / team id / role name / queue name / user id depending on recipient_type',
149
+ group: 'Recipient',
150
+ }),
151
+
152
+ access_level: Field.select(
153
+ ['read', 'edit', 'full'],
154
+ {
155
+ label: 'Access Level',
156
+ required: true,
157
+ defaultValue: 'read',
158
+ group: 'Recipient',
159
+ },
160
+ ),
161
+
162
+ active: Field.boolean({
163
+ label: 'Active',
164
+ required: false,
165
+ defaultValue: true,
166
+ description: 'Only active rules participate in lifecycle evaluation',
167
+ group: 'Lifecycle',
168
+ }),
169
+
170
+ created_at: Field.datetime({
171
+ label: 'Created At',
172
+ required: true,
173
+ defaultValue: 'NOW()',
174
+ readonly: true,
175
+ group: 'System',
176
+ }),
177
+
178
+ updated_at: Field.datetime({
179
+ label: 'Updated At',
180
+ required: false,
181
+ group: 'System',
182
+ }),
183
+ },
184
+
185
+ indexes: [
186
+ { fields: ['object_name', 'active'] },
187
+ { fields: ['name'], unique: true },
188
+ { fields: ['organization_id'] },
189
+ ],
190
+ });
@@ -3,7 +3,7 @@
3
3
  import type { Plugin, PluginContext } from '@objectstack/core';
4
4
  import type { EngineMiddleware, OperationContext } from '@objectstack/objectql';
5
5
  import type { IHttpServer } from '@objectstack/spec/contracts';
6
- import { SysRecordShare, SysSharingRule, SysShareLink } from '@objectstack/platform-objects/security';
6
+ import { SysRecordShare, SysSharingRule, SysShareLink } from './objects/index.js';
7
7
  import { SysDepartment, SysDepartmentMember } from '@objectstack/platform-objects/identity';
8
8
  import { SharingService, type SharingEngine } from './sharing-service.js';
9
9
  import { SharingRuleService } from './sharing-rule-service.js';
@@ -89,7 +89,37 @@ export class SharingServicePlugin implements Plugin {
89
89
  defaultDatasource: 'cloud',
90
90
  namespace: 'sys',
91
91
  objects: [SysRecordShare, SysSharingRule, SysDepartment, SysDepartmentMember, SysShareLink],
92
+ // ADR-0029 D7 — contribute the sharing entries into the Setup app's
93
+ // `group_access_control` slot (priority 200 so they sit after plugin-
94
+ // security's Roles / Permission Sets). This plugin owns these objects (K2).
95
+ navigationContributions: [
96
+ {
97
+ app: 'setup',
98
+ group: 'group_access_control',
99
+ priority: 200,
100
+ items: [
101
+ { id: 'nav_sharing_rules', type: 'object', label: 'Sharing Rules', objectName: 'sys_sharing_rule', icon: 'share-2', requiresObject: 'sys_sharing_rule', requiredPermissions: ['manage_platform_settings'] },
102
+ { id: 'nav_record_shares', type: 'object', label: 'Record Shares', objectName: 'sys_record_share', icon: 'link', requiresObject: 'sys_record_share', requiredPermissions: ['manage_platform_settings'] },
103
+ ],
104
+ },
105
+ ],
92
106
  });
107
+
108
+ // ADR-0029 D8 — contribute this plugin's object translations to the i18n
109
+ // service on kernel:ready (the i18n plugin may register after this one).
110
+ if (typeof (ctx as any).hook === 'function') {
111
+ (ctx as any).hook('kernel:ready', async () => {
112
+ try {
113
+ const i18n = ctx.getService<any>('i18n');
114
+ if (i18n && typeof i18n.loadTranslations === 'function') {
115
+ const { SharingTranslations } = await import('./translations/index.js');
116
+ for (const [locale, data] of Object.entries(SharingTranslations)) {
117
+ i18n.loadTranslations(locale, data as Record<string, unknown>);
118
+ }
119
+ }
120
+ } catch { /* i18n optional */ }
121
+ });
122
+ }
93
123
  ctx.logger.info('SharingServicePlugin: schema registered');
94
124
  }
95
125
 
@@ -0,0 +1,275 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * Auto-generated by 'os i18n extract' for locale 'en'.
5
+ * Edit translations in place; re-run extract (with --merge) to fill new gaps.
6
+ * Do not hand-edit the structure — only the leaf string values.
7
+ */
8
+
9
+ import type { TranslationData } from '@objectstack/spec/system';
10
+
11
+ export const enObjects: NonNullable<TranslationData['objects']> = {
12
+ sys_record_share: {
13
+ label: "Record Share",
14
+ pluralLabel: "Record Shares",
15
+ description: "Per-record sharing grant — extends OWD with explicit access",
16
+ fields: {
17
+ id: {
18
+ label: "Share ID"
19
+ },
20
+ object_name: {
21
+ label: "Object",
22
+ help: "Short object name of the shared record"
23
+ },
24
+ record_id: {
25
+ label: "Record",
26
+ help: "Primary key of the shared record within object_name"
27
+ },
28
+ recipient_type: {
29
+ label: "Recipient Type",
30
+ help: "Kind of principal that holds the grant",
31
+ options: {
32
+ user: "user",
33
+ group: "group",
34
+ role: "role",
35
+ role_and_subordinates: "role_and_subordinates",
36
+ guest: "guest"
37
+ }
38
+ },
39
+ recipient_id: {
40
+ label: "Recipient",
41
+ help: "ID of the user/group/role that receives access"
42
+ },
43
+ access_level: {
44
+ label: "Access Level",
45
+ help: "What the recipient can do — read | edit | full (transfer/share/delete)",
46
+ options: {
47
+ read: "read",
48
+ edit: "edit",
49
+ full: "full"
50
+ }
51
+ },
52
+ source: {
53
+ label: "Source",
54
+ help: "Why this grant exists — used by the rule evaluator to reconcile",
55
+ options: {
56
+ manual: "manual",
57
+ rule: "rule",
58
+ team: "team",
59
+ inherited: "inherited"
60
+ }
61
+ },
62
+ source_id: {
63
+ label: "Source ID",
64
+ help: "Rule name / team id when source != manual"
65
+ },
66
+ granted_by: {
67
+ label: "Granted By",
68
+ help: "User that created the grant (manual only)"
69
+ },
70
+ reason: {
71
+ label: "Reason",
72
+ help: "Optional free-text explanation surfaced to the recipient"
73
+ },
74
+ created_at: {
75
+ label: "Created At"
76
+ },
77
+ updated_at: {
78
+ label: "Updated At"
79
+ }
80
+ },
81
+ _views: {
82
+ granted_to_me: {
83
+ label: "Granted to Me"
84
+ },
85
+ granted_by_me: {
86
+ label: "Granted by Me"
87
+ },
88
+ by_object: {
89
+ label: "By Object"
90
+ },
91
+ manual_grants: {
92
+ label: "Manual Grants"
93
+ },
94
+ rule_grants: {
95
+ label: "Rule Grants"
96
+ },
97
+ all_shares: {
98
+ label: "All"
99
+ }
100
+ }
101
+ },
102
+ sys_sharing_rule: {
103
+ label: "Sharing Rule",
104
+ pluralLabel: "Sharing Rules",
105
+ description: "Declarative sharing rule that auto-materialises sys_record_share grants. Authored via defineSharingRule() in code or the Studio criteria builder.",
106
+ fields: {
107
+ id: {
108
+ label: "Rule ID"
109
+ },
110
+ organization_id: {
111
+ label: "Organization",
112
+ help: "Tenant that owns this rule; null = global"
113
+ },
114
+ name: {
115
+ label: "Name",
116
+ help: "Unique snake_case rule name"
117
+ },
118
+ label: {
119
+ label: "Display Label"
120
+ },
121
+ description: {
122
+ label: "Description"
123
+ },
124
+ object_name: {
125
+ label: "Object",
126
+ help: "Short object name (e.g. opportunity, account)"
127
+ },
128
+ criteria_json: {
129
+ label: "Criteria (FilterCondition JSON)",
130
+ help: "JSON FilterCondition matched against records of object_name. Empty = match all."
131
+ },
132
+ recipient_type: {
133
+ label: "Recipient Type",
134
+ help: "Kind of principal that receives access — expanded to user grants at evaluation time. `department` walks the parent_department_id tree; `team` is flat (better-auth).",
135
+ options: {
136
+ user: "user",
137
+ team: "team",
138
+ department: "department",
139
+ role: "role",
140
+ queue: "queue"
141
+ }
142
+ },
143
+ recipient_id: {
144
+ label: "Recipient",
145
+ help: "department id / team id / role name / queue name / user id depending on recipient_type"
146
+ },
147
+ access_level: {
148
+ label: "Access Level",
149
+ options: {
150
+ read: "read",
151
+ edit: "edit",
152
+ full: "full"
153
+ }
154
+ },
155
+ active: {
156
+ label: "Active",
157
+ help: "Only active rules participate in lifecycle evaluation"
158
+ },
159
+ created_at: {
160
+ label: "Created At"
161
+ },
162
+ updated_at: {
163
+ label: "Updated At"
164
+ }
165
+ },
166
+ _views: {
167
+ active: {
168
+ label: "Active"
169
+ },
170
+ inactive: {
171
+ label: "Inactive"
172
+ },
173
+ by_object: {
174
+ label: "By Object"
175
+ },
176
+ all_rules: {
177
+ label: "All"
178
+ }
179
+ }
180
+ },
181
+ sys_share_link: {
182
+ label: "Share Link",
183
+ pluralLabel: "Share Links",
184
+ description: "Opaque capability token granting access to a single record. Notion/Figma-style public link sharing.",
185
+ fields: {
186
+ id: {
187
+ label: "Link ID"
188
+ },
189
+ token: {
190
+ label: "Token",
191
+ help: "Opaque URL-safe random token (≥ 22 chars). The only secret in this row."
192
+ },
193
+ object_name: {
194
+ label: "Object",
195
+ help: "Short object name of the shared record (e.g. ai_conversation, contracts_contract)"
196
+ },
197
+ record_id: {
198
+ label: "Record",
199
+ help: "Primary key of the shared record within object_name"
200
+ },
201
+ permission: {
202
+ label: "Permission",
203
+ help: "What the link holder can do with the record",
204
+ options: {
205
+ view: "View",
206
+ comment: "Comment",
207
+ edit: "Edit"
208
+ }
209
+ },
210
+ audience: {
211
+ label: "Audience",
212
+ help: "Gating layer applied on top of the token check",
213
+ options: {
214
+ public: "Public (indexable)",
215
+ link_only: "Anyone with the link",
216
+ signed_in: "Signed-in users",
217
+ email: "Specific emails"
218
+ }
219
+ },
220
+ expires_at: {
221
+ label: "Expires At",
222
+ help: "When set, resolveToken returns null after this timestamp"
223
+ },
224
+ email_allowlist: {
225
+ label: "Email Allowlist",
226
+ help: "Lowercased addresses checked when audience=email"
227
+ },
228
+ password_hash: {
229
+ label: "Password Hash",
230
+ help: "Argon2/bcrypt hash. When set, the UI prompts for a password before rendering."
231
+ },
232
+ redact_fields: {
233
+ label: "Per-Link Redactions",
234
+ help: "Extra fields stripped from the response, on top of the object-default set"
235
+ },
236
+ label: {
237
+ label: "Label",
238
+ help: "Free-text shown in the share dialog (e.g. \"ACME Q3 contract\")"
239
+ },
240
+ revoked_at: {
241
+ label: "Revoked At",
242
+ help: "When set, the link is permanently disabled"
243
+ },
244
+ created_by: {
245
+ label: "Created By",
246
+ help: "Issuer of the link"
247
+ },
248
+ created_at: {
249
+ label: "Created At"
250
+ },
251
+ last_used_at: {
252
+ label: "Last Used At",
253
+ help: "Stamped by resolveToken; used by the dashboard to highlight active links"
254
+ },
255
+ use_count: {
256
+ label: "Use Count",
257
+ help: "Incremented by resolveToken on every successful resolution"
258
+ }
259
+ },
260
+ _views: {
261
+ active_links: {
262
+ label: "Active"
263
+ },
264
+ by_me: {
265
+ label: "Created by Me"
266
+ },
267
+ revoked: {
268
+ label: "Revoked"
269
+ },
270
+ all_links: {
271
+ label: "All"
272
+ }
273
+ }
274
+ }
275
+ };