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
21 changes: 21 additions & 0 deletions .changeset/spec-13-permission-model-v2.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
---
'@object-ui/app-shell': major
'@object-ui/components': patch
'@object-ui/console': patch
'@object-ui/types': patch
---

Adopt `@objectstack/spec` 13 (ADR-0090 Permission Model v2) across the workspace.

Every workspace package now depends on `@objectstack/spec` ^13.0.0 — the v2 major that renames role → position (D3), removes the profile concept (D2), makes OWD default to `private` when unset (D1), and drops the legacy `read`/`read_write`/`full` sharing aliases (D4). UI fallout fixed in the same sweep:

- **clientValidation**: the `role` draft-schema loader is now `position` → `PositionSchema` (fixes the `RoleSchema does not exist` build break, #2365); the dead `profile` loader is removed (D2).
- **Studio previews**: `RolePreview` → `PositionPreview` (flat — positions carry no hierarchy; the old parent-chain breadcrumb and "assign to a Profile" copy are gone). Legacy `role`/`profile` preview keys stay registered for pre-v2 backends.
- **OWD control** (`ObjectSettingsPanel`): removed the now-dead alias normalization (spec 13 rejects the aliases at authoring time) and the amber "fully public" warning — an unset sharing model now defaults to Private (D1), and the copy says so in both locales.
- **Fallback schemas / anchors / samples**: `position` replaces the hierarchical `role` fallback schema; `isProfile` dropped from the permission create-anchor and previews samples; permission-set viewer no longer renders a profile badge; console System hub counts `sys_position` instead of the removed `sys_role`.
- **Studio i18n**: type labels `Role/角色` → `Position/岗位`, `profile` label removed, Access-pillar heading and sharing copy rewritten to the v2 vocabulary.
- `@object-ui/types` now exports `SubmitBehavior` (was defined but missing from the public surface, breaking `@object-ui/plugin-form`'s re-export under a clean build).
- **External OWD dial (D11)**: the object Settings sharing card gains an `externalSharingModel` select (portal/partner baseline) with an inline wider-than-internal warning mirroring the publish-time lint.
- **Permission matrix OWD badges**: every object row now shows its record-level baseline (`OWD Public read`, `Ext Private`, or `OWD Private (default)` for the D1 fail-closed unset case) so grant edits carry their record-reach context.

The flow designer's approval assignee `role` kind is intentionally unchanged — spec 13 keeps it as the sole D3 exception (better-auth `sys_member.role` org-membership tier).
2 changes: 1 addition & 1 deletion apps/console/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,7 +88,7 @@
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/client": "^12.3.0",
"@objectstack/spec": "^12.3.0",
"@objectstack/spec": "^13.0.0",
"@tailwindcss/postcss": "^4.3.2",
"@tailwindcss/typography": "^0.5.20",
"@testing-library/jest-dom": "^6.9.1",
Expand Down
22 changes: 11 additions & 11 deletions apps/console/src/pages/system/SystemHubPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,7 @@
*
* Unified entry point for all system administration functions.
* Displays card-based overview linking to Apps, Users, Organizations,
* Roles, Permissions, Audit Log, Profile management pages, and
* Positions, Permissions, Audit Log, Profile management pages, and
* dynamically generated metadata type cards from the registry.
*/

Expand DownExpand Up@@ -65,7 +65,7 @@ export function SystemHubPage() {
const [counts, setCounts] = useState<Record<string, number | null>>({
users: null,
orgs: null,
roles: null,
positions: null,
permissions: null,
auditLogs: null,
});
Expand All@@ -76,17 +76,17 @@ export function SystemHubPage() {
setLoading(true);
try {
// TODO: Replace with count-specific API endpoint when available
const [usersRes, orgsRes, rolesRes, permsRes, logsRes] = await Promise.all([
const [usersRes, orgsRes, positionsRes, permsRes, logsRes] = await Promise.all([
dataSource.find('sys_user').catch(() => ({ data: [] })),
dataSource.find('sys_org').catch(() => ({ data: [] })),
dataSource.find('sys_role').catch(() => ({ data: [] })),
dataSource.find('sys_position').catch(() => ({ data: [] })),
dataSource.find('sys_permission').catch(() => ({ data: [] })),
dataSource.find('sys_audit_log').catch(() => ({ data: [] })),
]);
setCounts({
users: usersRes.data?.length ?? 0,
orgs: orgsRes.data?.length ?? 0,
roles: rolesRes.data?.length ?? 0,
positions: positionsRes.data?.length ?? 0,
permissions: permsRes.data?.length ?? 0,
auditLogs: logsRes.data?.length ?? 0,
});
Expand DownExpand Up@@ -148,12 +148,12 @@ export function SystemHubPage() {
count: counts.orgs,
},
{
title: 'Roles',
description: 'Configure roles and access levels',
title: 'Positions',
description: 'Configure positions and access levels',
icon: Shield,
href: `${basePath}/system/roles`,
countLabel: 'roles',
count: counts.roles,
href: `${basePath}/system/positions`,
countLabel: 'positions',
count: counts.positions,
},
{
title: 'Permissions',
Expand DownExpand Up@@ -223,7 +223,7 @@ export function SystemHubPage() {
<div className="min-w-0">
<h1 className="text-xl sm:text-2xl font-bold tracking-tight">System Settings</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage applications, users, roles, permissions, and system configuration
Manage applications, users, positions, permissions, and system configuration
</p>
</div>

Expand Down
10 changes: 5 additions & 5 deletions apps/console/src/pages/system/systemObjects.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,23 +58,23 @@ export const systemObjects = [
],
},
{
name: 'sys_role',
label: 'Roles',
name: 'sys_position',
label: 'Positions',
icon: 'Shield',
fields: [
{ name: 'id', type: 'text', label: 'ID', readonly: true },
{ name: 'name', type: 'text', label: 'Role Name', required: true },
{ name: 'name', type: 'text', label: 'Position Name', required: true },
{ name: 'description', type: 'textarea', label: 'Description' },
{ name: 'permissions', type: 'text', label: 'Permissions' },
{ name: 'isSystem', type: 'boolean', label: 'System Role', readonly: true },
{ name: 'isSystem', type: 'boolean', label: 'System Position', readonly: true },
{ name: 'userCount', type: 'number', label: 'Users', readonly: true },
{ name: 'createdAt', type: 'datetime', label: 'Created At', readonly: true },
],
titleFormat: '{name}',
views: [
{
name: 'all',
label: 'All Roles',
label: 'All Positions',
type: 'grid',
columns: ['name', 'description', 'isSystem', 'userCount'],
},
Expand Down
16 changes: 1 addition & 15 deletions apps/console/src/preview-samples.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,7 +238,6 @@ export const SAMPLES: Record<string, Record<string, unknown>> = {
permission: {
name: 'sales_rep',
label: 'Sales Rep',
isProfile: false,
objects: {
sales_order: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
account: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false },
Expand All@@ -250,23 +249,10 @@ export const SAMPLES: Record<string, Record<string, unknown>> = {
tabPermissions: { crm: 'visible', admin: 'hidden' },
},

profile: {
name: 'standard_user',
label: 'Standard User',
isProfile: true,
objects: {
account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
sales_order: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
},
systemPermissions: ['runReports'],
tabPermissions: { crm: 'visible' },
},

role: {
position: {
name: 'sales_manager',
label: 'Sales Manager',
description: 'Manages the regional sales team.',
parent: 'vp_sales',
},

datasource: {
Expand Down
2 changes: 1 addition & 1 deletion apps/site/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@
"@object-ui/plugin-view": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^12.3.0",
"@objectstack/spec": "^13.0.0",
"fumadocs-core": "16.10.7",
"fumadocs-mdx": "15.0.13",
"fumadocs-ui": "16.10.7",
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/lint": "^12.6.0",
"@objectstack/spec": "^12.3.0",
"@objectstack/spec": "^13.0.0",
"@sentry/react": "^10.63.0",
"jsonc-parser": "^3.3.1",
"lucide-react": "^1.22.0",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -230,11 +230,34 @@ describe('PermissionMatrixEditor — private posture badge (ADR-0066 ④)', () =

await screen.findByText('Account');
// The private object's row carries the badge (with the wildcard hint)…
const badges = screen.getAllByText('Private');
// (every row also shows an ADR-0090 OWD badge, so match the exact posture
// badge text, not the substring)
const badges = screen.getAllByText('Private', { exact: true });
expect(badges).toHaveLength(1);
expect(badges[0].closest('tr')!.textContent).toContain('a_account');
expect(badges[0]).toHaveAttribute('title', expect.stringContaining('wildcard'));
// …and the public row does not.
expect(screen.getByText('a_contact').closest('tr')!.textContent).not.toContain('Private');
// …and the public row does not carry the posture badge.
const contactRow = screen.getByText('a_contact').closest('tr')!;
expect(within(contactRow).queryByText('Private', { exact: true })).toBeNull();
});

it('shows an OWD badge on every row (authored value or the D1 private default)', async () => {
const server = freshServer();
server.packageObjects = [
{ name: 'a_account', label: 'Account', sharingModel: 'public_read', externalSharingModel: 'private' } as any,
{ name: 'a_contact' },
];
clientImpl = makeClient(server);
renderMatrix();

await screen.findByText('Account');
// Authored OWD pair renders both dials…
const accountRow = screen.getByText('Account').closest('tr')!;
expect(accountRow.textContent).toContain('OWD Public read');
expect(accountRow.textContent).toContain('Ext Private');
// …an unset OWD renders the fail-closed D1 default and no Ext badge.
const contactRow = screen.getByText('a_contact').closest('tr')!;
expect(contactRow.textContent).toContain('OWD Private (default)');
expect(contactRow.textContent).not.toContain('Ext ');
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,25 @@ interface ObjectSummary {
* admins editing the matrix know a wildcard-only set does not reach it.
*/
accessDefault?: 'public' | 'private';
/**
* [ADR-0090 D1/D11] The object's authored OWD pair. Record-level baseline
* context for the grants edited here: object CRUD in this matrix gates the
* operation, the OWD decides WHICH records it reaches (own vs org-wide).
* `owd` unset renders as the D1 fail-closed default (private).
*/
owd?: string;
owdExternal?: string;
}

/** Localized short label for an OWD value; falls back to the raw value. */
function owdLabel(t: (k: string) => string, value: string): string {
const key: Record<string, string> = {
private: 'perm.owd.private',
public_read: 'perm.owd.public_read',
public_read_write: 'perm.owd.public_read_write',
controlled_by_parent: 'perm.owd.controlled_by_parent',
};
return key[value] ? t(key[value]) : value;
}

interface FieldSummary {
Expand DownExpand Up@@ -199,6 +218,9 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
name: String(item?.name ?? ''),
label: item?.label,
accessDefault: item?.access?.default as ObjectSummary['accessDefault'],
owd: typeof item?.sharingModel === 'string' ? item.sharingModel : undefined,
owdExternal:
typeof item?.externalSharingModel === 'string' ? item.externalSharingModel : undefined,
};
})
.filter((o) => !!o.name)
Expand DownExpand Up@@ -676,6 +698,22 @@ function PermissionTable({
{t('perm.posture.private')}
</Badge>
)}
<Badge
variant="outline"
className="ml-2 text-[10px] px-1.5 py-0 align-middle text-muted-foreground"
title={t('perm.owd.tip')}
>
{`OWD ${o.owd ? owdLabel(t, o.owd) : t('perm.owd.defaultPrivate')}`}
</Badge>
{o.owdExternal && (
<Badge
variant="outline"
className="ml-1 text-[10px] px-1.5 py-0 align-middle text-muted-foreground"
title={t('perm.owd.ext.tip')}
>
{`Ext ${owdLabel(t, o.owdExternal)}`}
</Badge>
)}
</td>
{objectActions.map((a) => (
<td key={a.key as string} className="text-center px-2 py-1.5">
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/src/views/metadata-admin/anchors.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -338,7 +338,7 @@ export function registerBuiltinAnchors(): void {
groupLabel: 'Permissions',
order: 60,
}],
createFields: ['label', 'name', 'isProfile'],
createFields: ['label', 'name'],
createDerive: [
{ from: 'label', to: 'name', transform: 'slugify', untilUserEdits: true },
],
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,15 +90,16 @@ const LOADERS: Record<string, SchemaLoader> = {
// security
// NOTE: use PermissionSetSchema from /security, NOT PluginPermissionSchema from /kernel —
// the kernel one is the plugin-sandbox permission ({id,resource,actions}), not the
// metadata permission set ({name,isProfile,objects,fields}). See
// metadata permission set ({name,objects,fields}). See
// packages/spec/src/kernel/metadata-type-schemas.ts for the canonical mapping.
permission: async () => (await import('@objectstack/spec/security')).PermissionSetSchema as unknown as ZodLikeSchema,
profile: async () => (await import('@objectstack/spec/security')).PermissionSetSchema as unknown as ZodLikeSchema,
// `policy` intentionally omitted — spec 11.2.0 dropped `PolicySchema` and the metadata-type
// registry has no `policy` schema; drafts fall through to server-side validation (see top).
// `profile` intentionally omitted — ADR-0090 D2 removed the profile concept (spec 13);
// `role` is likewise gone, renamed to `position` (ADR-0090 D3).

// identity
role: async () => (await import('@objectstack/spec/identity')).RoleSchema as unknown as ZodLikeSchema,
position: async () => (await import('@objectstack/spec/identity')).PositionSchema as unknown as ZodLikeSchema,

// api
api: async () => (await import('@objectstack/spec/api')).ApiEndpointSchema as unknown as ZodLikeSchema,
Expand Down
34 changes: 6 additions & 28 deletions packages/app-shell/src/views/metadata-admin/default-schemas.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,38 +42,16 @@ const headerProps = {

/** Per-type fallback schemas. */
const SCHEMAS: Record<string, Record<string, unknown>> = {
role: {
// Position is deliberately flat (ADR-0090 D3) — no level, no parent; the
// hierarchy lives on the business-unit tree. `role`/`profile` schemas are
// gone with the spec-13 vocabulary (positions replace roles; D2 removed
// the profile concept).
position: {
type: 'object',
title: 'Role',
title: 'Position',
required: ['name'],
properties: {
...headerProps,
level: {
type: 'string',
title: 'Level',
description: 'Coarse seniority tier (used by UI / sort order).',
enum: ['member', 'lead', 'manager', 'director', 'executive', 'admin'],
},
parentRole: {
type: 'string',
title: 'Parent Role',
description: 'Optional parent role name for hierarchy.',
},
},
},

profile: {
type: 'object',
title: 'Profile',
required: ['name'],
properties: {
...headerProps,
isProfile: { type: 'boolean', title: 'Is profile', description: 'Profiles are mutually exclusive (one per user).' },
objects: {
type: 'object',
title: 'Object Permissions',
description: 'Edit this via the Permission Matrix tab for a row-by-row UI.',
},
},
},

Expand Down
Loading
Loading