Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/adr-0029-k2-security-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@objectstack/platform-objects": minor
"@objectstack/plugin-security": minor
"@objectstack/plugin-sharing": minor
---

ADR-0029 K2 — security domain ownership (RBAC + sharing) + Setup nav contributions.

Moves the security objects out of the `@objectstack/platform-objects` monolith
into the two capability plugins that already register and operate them, split by
concern (the two are orthogonal — sharing objects never reference RBAC objects):

- **`@objectstack/plugin-security`** (RBAC) gains `sys_role`,
`sys_permission_set`, `sys_user_permission_set`, `sys_role_permission_set`,
and the `defaultPermissionSets` seed (which its `bootstrap-platform-admin`
already consumes). The RBAC + default-permission-set tests move with them.
- **`@objectstack/plugin-sharing`** gains `sys_record_share`,
`sys_sharing_rule`, `sys_share_link`.
- `@objectstack/platform-objects` no longer defines/exports any security
objects; the `/security` subpath is now an empty barrel. Runtime is unchanged
(both plugins already registered these objects at runtime).

**D7 navigation** — the Setup app's `group_access_control` is now assembled from
three sources: `plugin-security` contributes Roles / Permission Sets (priority
100), `plugin-sharing` contributes Sharing Rules / Record Shares (priority 200),
and `platform-objects` keeps only API Keys (`sys_api_key`, an identity object,
priority 300) — preserving the original menu order.

**i18n (D8)** — the objects are removed from the `platform-objects` i18n extract
config; existing generated bundles keep working at runtime (object-name keyed).
Migrating the i18n extraction to the owning plugins remains the tracked
follow-up.
23 changes: 6 additions & 17 deletions packages/platform-objects/scripts/i18n-extract.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,10 @@ import {
} from '../src/identity/index.js';

// ── Security ──────────────────────────────────────────────────────────────
import {
SysRole,
SysPermissionSet,
SysUserPermissionSet,
SysRolePermissionSet,
SysRecordShare,
SysSharingRule,
SysShareLink,
} from '../src/security/index.js';
// RBAC objects moved to @objectstack/plugin-security and sharing objects to
// @objectstack/plugin-sharing (ADR-0029 K2 / D8). Their i18n extraction must
// move to those plugins before the next regeneration; existing generated
// bundles keep working until then.

// ── Audit ─────────────────────────────────────────────────────────────────
import {
Expand Down Expand Up @@ -146,14 +141,8 @@ export default defineStack({
SysOauthConsent,
SysJwks,

// Security
SysRole,
SysPermissionSet,
SysUserPermissionSet,
SysRolePermissionSet,
SysRecordShare,
SysSharingRule,
SysShareLink,
// Security: RBAC moved to @objectstack/plugin-security, sharing to
// @objectstack/plugin-sharing (ADR-0029 K2 / D8).

// Audit
SysAuditLog,
Expand Down
23 changes: 14 additions & 9 deletions packages/platform-objects/src/apps/setup-nav.contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
* in `@objectstack/platform-objects`. They are registered alongside
* `SETUP_APP` (via `plugin-auth`'s `manifest.register`).
*
* Some groups are intentionally **absent** here because a capability plugin
* owns them and contributes their entries:
* - `group_integrations` → `@objectstack/plugin-webhooks` (ADR-0029 K2.a)
* - `group_approvals` → `@objectstack/plugin-approvals` (ADR-0029 K2.b)
* Some entries/groups are intentionally contributed by the capability plugin
* that owns the underlying objects rather than living here (ADR-0029 K2):
* - `group_integrations` → `@objectstack/plugin-webhooks` (K2.a)
* - `group_approvals` → `@objectstack/plugin-approvals` (K2.b)
* - `group_access_control` Roles / Permission Sets → `@objectstack/plugin-security`
* - `group_access_control` Sharing Rules / Record Shares → `@objectstack/plugin-sharing`
* As each remaining domain moves to its capability plugin, its entries move out
* of this file into that plugin the same way.
*
Expand Down Expand Up @@ -57,12 +59,15 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
{
app: 'setup',
group: 'group_access_control',
priority: BASE_PRIORITY,
// Priority 300 keeps API Keys after plugin-security's Roles / Permission
// Sets (100) and plugin-sharing's Sharing Rules / Record Shares (200),
// preserving the original menu order.
priority: 300,
items: [
{ id: 'nav_roles', type: 'object', label: 'Roles', objectName: 'sys_role', icon: 'shield-check' },
{ id: 'nav_permission_sets', type: 'object', label: 'Permission Sets', objectName: 'sys_permission_set', icon: 'lock' },
{ id: 'nav_sharing_rules', type: 'object', label: 'Sharing Rules', objectName: 'sys_sharing_rule', icon: 'share-2', requiresObject: 'sys_sharing_rule', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_record_shares', type: 'object', label: 'Record Shares', objectName: 'sys_record_share', icon: 'link', requiresObject: 'sys_record_share', requiredPermissions: ['manage_platform_settings'] },
// Roles / Permission Sets are contributed by @objectstack/plugin-security
// and Sharing Rules / Record Shares by @objectstack/plugin-sharing
// (ADR-0029 K2). Only API Keys (sys_api_key, an identity object owned by
// plugin-auth) remains a platform-objects base entry here.
{ id: 'nav_api_keys', type: 'object', label: 'API Keys', objectName: 'sys_api_key', icon: 'key', requiredPermissions: ['manage_platform_settings'] },
],
},
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-objects/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*
* Subpath imports available:
* @objectstack/platform-objects/identity — user, session, org, team, api-key, ...
* @objectstack/platform-objects/security — role, permission-set
* @objectstack/platform-objects/security — (empty; RBAC moved to @objectstack/plugin-security, sharing to @objectstack/plugin-sharing per ADR-0029)
* @objectstack/platform-objects/audit — audit-log, presence
* @objectstack/platform-objects/integration — (empty; sys_webhook moved to @objectstack/plugin-webhooks per ADR-0029)
* @objectstack/platform-objects/metadata — sys_metadata, sys_metadata_history
Expand Down
120 changes: 3 additions & 117 deletions packages/platform-objects/src/platform-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,9 @@ import {
SysUserPreference,
SysVerification,
} from './identity/index.js';
import {
SysPermissionSet,
SysRole,
SysUserPermissionSet,
SysRolePermissionSet,
defaultPermissionSets,
} from './security/index.js';
// RBAC objects (SysRole/SysPermissionSet/… + defaultPermissionSets) moved to
// @objectstack/plugin-security and the sharing objects to
// @objectstack/plugin-sharing per ADR-0029 K2 — see their packages' tests.
import { SysAuditLog, SysPresence } from './audit/index.js';
// sys_webhook moved to @objectstack/plugin-webhooks per ADR-0029 (K2.a).
import {
Expand All @@ -46,10 +42,6 @@ const systemObjects = [
['SysApiKey', SysApiKey, 'sys_api_key'],
['SysTwoFactor', SysTwoFactor, 'sys_two_factor'],
['SysUserPreference', SysUserPreference, 'sys_user_preference'],
['SysRole', SysRole, 'sys_role'],
['SysPermissionSet', SysPermissionSet, 'sys_permission_set'],
['SysUserPermissionSet', SysUserPermissionSet, 'sys_user_permission_set'],
['SysRolePermissionSet', SysRolePermissionSet, 'sys_role_permission_set'],
['SysAuditLog', SysAuditLog, 'sys_audit_log'],
['SysPresence', SysPresence, 'sys_presence'],
['SysMetadata', SysMetadata, 'sys_metadata'],
Expand All @@ -71,117 +63,11 @@ describe('@objectstack/platform-objects', () => {
expect((object as any).tableName).toBeUndefined();
});

describe('default permission sets', () => {
it('exposes the four canonical platform permission sets', () => {
const names = defaultPermissionSets.map((p) => p.name).sort();
expect(names).toEqual([
'admin_full_access',
'member_default',
'organization_admin',
'viewer_readonly',
]);
});

it('organization_admin has setup.access but not studio.access / manage_metadata / manage_platform_settings', () => {
const orgAdmin = defaultPermissionSets.find((p) => p.name === 'organization_admin')!;
const sys = orgAdmin.systemPermissions ?? [];
expect(sys).toContain('setup.access');
expect(sys).toContain('manage_org_users');
expect(sys).not.toContain('studio.access');
expect(sys).not.toContain('manage_metadata');
expect(sys).not.toContain('manage_platform_settings');
});

it('organization_admin is read-only on global RBAC tables to prevent privilege escalation', () => {
const orgAdmin = defaultPermissionSets.find((p) => p.name === 'organization_admin')!;
for (const obj of [
'sys_role',
'sys_permission_set',
'sys_role_permission_set',
'sys_user_permission_set',
'sys_user_role',
]) {
const perms = (orgAdmin.objects as any)[obj];
expect(perms, `${obj} explicit perms missing`).toBeDefined();
expect(perms.allowRead).toBe(true);
expect(perms.allowCreate).toBe(false);
expect(perms.allowEdit).toBe(false);
expect(perms.allowDelete).toBe(false);
}
});

it('admin_full_access grants wildcard CRUD with viewAll/modifyAll', () => {
const admin = defaultPermissionSets.find((p) => p.name === 'admin_full_access')!;
const wildcard = admin.objects['*'];
expect(wildcard).toBeDefined();
expect(wildcard.allowRead).toBe(true);
expect(wildcard.allowCreate).toBe(true);
expect(wildcard.allowEdit).toBe(true);
expect(wildcard.allowDelete).toBe(true);
expect(wildcard.viewAllRecords).toBe(true);
expect(wildcard.modifyAllRecords).toBe(true);
});

it('member_default ships tenant + owner RLS policies plus better-auth system table guards', () => {
const member = defaultPermissionSets.find((p) => p.name === 'member_default')!;
const policyNames = (member.rowLevelSecurity ?? []).map((p) => p.name).sort();
expect(policyNames).toEqual([
'owner_only_deletes',
'owner_only_writes',
'sys_account_self',
'sys_api_key_self',
'sys_device_code_self',
'sys_oauth_access_token_self',
'sys_oauth_application_self',
'sys_oauth_consent_self',
'sys_oauth_refresh_token_self',
'sys_organization_self',
'sys_session_self',
'sys_team_member_self',
'sys_two_factor_self',
'sys_user_org_members',
'sys_user_preference_self',
'sys_user_self',
'tenant_isolation',
]);
const tenantPolicy = (member.rowLevelSecurity ?? []).find((p) => p.name === 'tenant_isolation')!;
expect(tenantPolicy.using).toBe('organization_id = current_user.organization_id');
const orgSelf = (member.rowLevelSecurity ?? []).find((p) => p.name === 'sys_organization_self')!;
expect(orgSelf.object).toBe('sys_organization');
expect(orgSelf.using).toBe('id = current_user.organization_id');
// The user_id-keyed better-auth tables (sys_session etc.) get
// per-object carve-outs because the wildcard tenant_isolation
// policy would otherwise DENY them (they lack organization_id).
const sessionSelf = (member.rowLevelSecurity ?? []).find((p) => p.name === 'sys_session_self')!;
expect(sessionSelf.object).toBe('sys_session');
expect(sessionSelf.using).toBe('user_id = current_user.id');
});

it('viewer_readonly denies writes', () => {
const viewer = defaultPermissionSets.find((p) => p.name === 'viewer_readonly')!;
const wildcard = viewer.objects['*'];
expect(wildcard.allowRead).toBe(true);
expect(wildcard.allowCreate).toBe(false);
expect(wildcard.allowEdit).toBe(false);
expect(wildcard.allowDelete).toBe(false);
});
});

describe('sysadmin row actions', () => {
// Setup-App admins must be able to drive the access-control lifecycle
// without dropping to SQL. These assertions lock in the high-traffic
// affordances (activate/deactivate/clone for RBAC objects; unlink
// for identity links) so they cannot silently regress.
it('SysRole exposes activate/deactivate/clone/set-default row actions', () => {
const names = (SysRole.actions ?? []).map((a) => a.name).sort();
expect(names).toEqual(['activate_role', 'clone_role', 'deactivate_role', 'set_default_role']);
});

it('SysPermissionSet exposes activate/deactivate/clone row actions', () => {
const names = (SysPermissionSet.actions ?? []).map((a) => a.name).sort();
expect(names).toEqual(['activate_permission_set', 'clone_permission_set', 'deactivate_permission_set']);
});

it('SysAccount exposes an unlink-account row action wired to better-auth', () => {
const unlink = (SysAccount.actions ?? []).find((a) => a.name === 'unlink_account');
expect(unlink).toBeDefined();
Expand Down
20 changes: 12 additions & 8 deletions packages/platform-objects/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

/**
* platform-objects/security — Security & Permission Platform Objects
*
* **Empty since ADR-0029 (K2).** The RBAC objects (role / permission-set /
* user-permission-set / role-permission-set + default permission sets) moved
* to `@objectstack/plugin-security`, and the sharing objects (record-share /
* sharing-rule / share-link) moved to `@objectstack/plugin-sharing`, so each
* plugin owns its data model and behavior as one unit. Import them from the
* owning plugin instead.
*
* The subpath (`@objectstack/platform-objects/security`) is retained as an
* empty barrel to avoid churning the package `exports` map / tsup entries
* during the incremental decomposition; it can be removed at ADR-0029 K4.
*/

export { SysRole } from './sys-role.object.js';
export { SysPermissionSet } from './sys-permission-set.object.js';
export { SysUserPermissionSet } from './sys-user-permission-set.object.js';
export { SysRolePermissionSet } from './sys-role-permission-set.object.js';
export { SysRecordShare } from './sys-record-share.object.js';
export { SysSharingRule } from './sys-sharing-rule.object.js';
export { SysShareLink } from './sys-share-link.object.js';
export { defaultPermissionSets } from './default-permission-sets.js';
export {};
2 changes: 1 addition & 1 deletion packages/plugins/plugin-security/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
SysUserPermissionSet,
SysRolePermissionSet,
defaultPermissionSets,
} from '@objectstack/platform-objects/security';
} from './objects/index.js';

export const SECURITY_PLUGIN_ID = 'com.objectstack.plugin-security';
export const SECURITY_PLUGIN_VERSION = '1.0.0';
Expand Down
16 changes: 16 additions & 0 deletions packages/plugins/plugin-security/src/objects/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* RBAC objects owned by `@objectstack/plugin-security` (ADR-0029 K2).
*
* Moved here from the `@objectstack/platform-objects` monolith so the plugin
* owns its data model, behavior (bootstrap-platform-admin), and admin menu as
* one unit. The sharing objects (record-share / sharing-rule / share-link)
* live in `@objectstack/plugin-sharing`.
*/

export { SysRole } from './sys-role.object.js';
export { SysPermissionSet } from './sys-permission-set.object.js';
export { SysUserPermissionSet } from './sys-user-permission-set.object.js';
export { SysRolePermissionSet } from './sys-role-permission-set.object.js';
export { defaultPermissionSets } from './default-permission-sets.js';
Loading