Skip to content

Commit 7ddf75d

Browse files
guguclaude
andcommitted
Add "All tables" wildcard option and generate cedar policy directly from items
- Add "All tables" (*) option to table dropdowns in policy list editor - Generate cedar policy directly from CedarPolicyItem[] via policyItemsToCedarPolicy, bypassing the Permissions intermediary to preserve wildcard resource refs - Wildcard tables produce resource == RocketAdmin::Table::"connId/*" - Parser handles wildcard table entries and applies them to all tables - Fix dark theme contrast for table name and empty state text - Remove "Cedar policy" labels from group dialogs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5849a54 commit 7ddf75d

10 files changed

Lines changed: 118 additions & 37 deletions

frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
}
66

77
.empty-state {
8-
color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6));
8+
opacity: 0.7;
99
font-size: 14px;
1010
padding: 12px 0;
1111
}
@@ -46,7 +46,7 @@
4646

4747
.policy-item__table {
4848
font-size: 14px;
49-
color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6));
49+
opacity: 0.7;
5050
}
5151

5252
.policy-item__actions {

frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
<mat-form-field *ngIf="editNeedsTable" appearance="outline" class="policy-field">
4141
<mat-label>Table</mat-label>
4242
<mat-select [(ngModel)]="editTableName" [ngModelOptions]="{standalone: true}">
43+
<mat-option value="*">All tables</mat-option>
4344
<mat-option *ngFor="let table of availableTables" [value]="table.tableName">
4445
{{ table.displayName }}
4546
</mat-option>
@@ -72,6 +73,7 @@
7273
<mat-form-field *ngIf="needsTable" appearance="outline" class="policy-field">
7374
<mat-label>Table</mat-label>
7475
<mat-select [(ngModel)]="newTableName" [ngModelOptions]="{standalone: true}">
76+
<mat-option value="*">All tables</mat-option>
7577
<mat-option *ngFor="let table of availableTables" [value]="table.tableName">
7678
{{ table.displayName }}
7779
</mat-option>

frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ describe('CedarPolicyListComponent', () => {
5050
expect(component.policies[0].tableName).toBe('customers');
5151
});
5252

53+
it('should add a table policy with wildcard tableName', () => {
54+
component.showAddForm = true;
55+
component.newAction = 'table:edit';
56+
component.newTableName = '*';
57+
component.addPolicy();
58+
59+
expect(component.policies.length).toBe(1);
60+
expect(component.policies[0].action).toBe('table:edit');
61+
expect(component.policies[0].tableName).toBe('*');
62+
});
63+
5364
it('should not add policy without action', () => {
5465
component.showAddForm = true;
5566
component.newAction = '';
@@ -113,6 +124,7 @@ describe('CedarPolicyListComponent', () => {
113124
it('should return correct table display names', () => {
114125
expect(component.getTableDisplayName('customers')).toBe('Customers');
115126
expect(component.getTableDisplayName('unknown')).toBe('unknown');
127+
expect(component.getTableDisplayName('*')).toBe('All tables');
116128
});
117129

118130
it('should detect needsTable correctly', () => {

frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export class CedarPolicyListComponent {
5858
}
5959

6060
getTableDisplayName(tableName: string): string {
61+
if (tableName === '*') return 'All tables';
6162
return this.availableTables.find((t) => t.tableName === tableName)?.displayName || tableName;
6263
}
6364

frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ <h1 mat-dialog-title>Create group of users</h1>
99

1010
<div class="cedar-policy-section">
1111
<div class="editor-mode-toggle">
12-
<label class="cedar-policy-label">Cedar policy (optional)</label>
1312
<mat-button-toggle-group [value]="editorMode" (change)="onEditorModeChange($event.value)">
1413
<mat-button-toggle value="form">Form</mat-button-toggle>
1514
<mat-button-toggle value="code">Code</mat-button-toggle>

frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ import { CodeEditorModule, CodeEditorService } from '@ngstack/code-editor';
1010
import { Angulartics2 } from 'angulartics2';
1111
import posthog from 'posthog-js';
1212
import { registerCedarLanguage } from 'src/app/lib/cedar-monaco-language';
13-
import { generateCedarPolicy } from 'src/app/lib/cedar-policy-generator';
14-
import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToPermissions } from 'src/app/lib/cedar-policy-items';
13+
import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToCedarPolicy } from 'src/app/lib/cedar-policy-items';
1514
import { parseCedarPolicy } from 'src/app/lib/cedar-policy-parser';
1615
import { normalizeTableName } from 'src/app/lib/normalize';
1716
import { TablePermission } from 'src/app/models/user';
@@ -112,9 +111,8 @@ export class GroupAddDialogComponent implements OnInit {
112111
if (mode === this.editorMode) return;
113112

114113
if (mode === 'code') {
115-
// Form → Code: convert policy items to cedar text
116-
const permissions = policyItemsToPermissions(this.policyItems, this.connectionID, '__new__', this.allTables);
117-
this.cedarPolicy = generateCedarPolicy(this.connectionID, permissions);
114+
// Form → Code: convert policy items directly to cedar text
115+
this.cedarPolicy = policyItemsToCedarPolicy(this.policyItems, this.connectionID, '__new__');
118116
this.cedarPolicyModel = {
119117
language: 'cedar',
120118
uri: `cedar-policy-create-${Date.now()}.cedar`,
@@ -134,8 +132,7 @@ export class GroupAddDialogComponent implements OnInit {
134132

135133
// If in form mode, generate cedar policy from policy items
136134
if (this.editorMode === 'form') {
137-
const permissions = policyItemsToPermissions(this.policyItems, this.connectionID, '__new__', this.allTables);
138-
this.cedarPolicy = generateCedarPolicy(this.connectionID, permissions);
135+
this.cedarPolicy = policyItemsToCedarPolicy(this.policyItems, this.connectionID, '__new__');
139136
}
140137

141138
this._usersService.createUsersGroup(this.connectionID, this.groupTitle, this.cedarPolicy || null).subscribe(

frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ <h1 mat-dialog-title>Edit group</h1>
99

1010
<div class="cedar-policy-section">
1111
<div class="editor-mode-toggle">
12-
<label class="cedar-policy-label">Cedar policy</label>
1312
<mat-button-toggle-group [value]="editorMode" (change)="onEditorModeChange($event.value)">
1413
<mat-button-toggle value="form">Form</mat-button-toggle>
1514
<mat-button-toggle value="code">Code</mat-button-toggle>

frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
88
import { MatInputModule } from '@angular/material/input';
99
import { CodeEditorModule, CodeEditorService } from '@ngstack/code-editor';
1010
import { registerCedarLanguage } from 'src/app/lib/cedar-monaco-language';
11-
import { generateCedarPolicy } from 'src/app/lib/cedar-policy-generator';
12-
import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToPermissions } from 'src/app/lib/cedar-policy-items';
11+
import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToCedarPolicy } from 'src/app/lib/cedar-policy-items';
1312
import { parseCedarPolicy } from 'src/app/lib/cedar-policy-parser';
1413
import { normalizeTableName } from 'src/app/lib/normalize';
1514
import { TablePermission } from 'src/app/models/user';
@@ -119,9 +118,8 @@ export class GroupNameEditDialogComponent implements OnInit {
119118
if (mode === this.editorMode) return;
120119

121120
if (mode === 'code') {
122-
// Form → Code: convert policy items to cedar text
123-
const permissions = policyItemsToPermissions(this.policyItems, this.connectionID, this.group.id, this.allTables);
124-
this.cedarPolicy = generateCedarPolicy(this.connectionID, permissions);
121+
// Form → Code: convert policy items directly to cedar text
122+
this.cedarPolicy = policyItemsToCedarPolicy(this.policyItems, this.connectionID, this.group.id);
125123
this.cedarPolicyModel = {
126124
language: 'cedar',
127125
uri: `cedar-policy-edit-${this.group.id}-${Date.now()}.cedar`,
@@ -141,8 +139,7 @@ export class GroupNameEditDialogComponent implements OnInit {
141139

142140
// If in form mode, generate cedar policy from policy items
143141
if (this.editorMode === 'form') {
144-
const permissions = policyItemsToPermissions(this.policyItems, this.connectionID, this.group.id, this.allTables);
145-
this.cedarPolicy = generateCedarPolicy(this.connectionID, permissions);
142+
this.cedarPolicy = policyItemsToCedarPolicy(this.policyItems, this.connectionID, this.group.id);
146143
}
147144

148145
this._usersService.editUsersGroupName(this.group.id, this.groupTitle, this.cedarPolicy || null).subscribe(

frontend/src/app/lib/cedar-policy-items.ts

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,78 @@ export function permissionsToPolicyItems(permissions: Permissions): CedarPolicyI
3737
items.push({ action: 'group:read' });
3838
}
3939

40-
for (const table of permissions.tables) {
41-
const access = table.accessLevel;
42-
if (access.visibility) items.push({ action: 'table:read', tableName: table.tableName });
43-
if (access.add) items.push({ action: 'table:add', tableName: table.tableName });
44-
if (access.edit) items.push({ action: 'table:edit', tableName: table.tableName });
45-
if (access.delete) items.push({ action: 'table:delete', tableName: table.tableName });
40+
// Check if all tables share the same permissions — collapse to wildcard '*'
41+
const tables = permissions.tables;
42+
if (tables.length > 0) {
43+
const allRead = tables.every((t) => t.accessLevel.visibility);
44+
const allAdd = tables.every((t) => t.accessLevel.add);
45+
const allEdit = tables.every((t) => t.accessLevel.edit);
46+
const allDelete = tables.every((t) => t.accessLevel.delete);
47+
const anyTableAccess = allRead || allAdd || allEdit || allDelete;
48+
49+
if (anyTableAccess && allRead && allAdd && allEdit && allDelete) {
50+
// All tables have full access — single wildcard per action
51+
items.push({ action: 'table:read', tableName: '*' });
52+
items.push({ action: 'table:add', tableName: '*' });
53+
items.push({ action: 'table:edit', tableName: '*' });
54+
items.push({ action: 'table:delete', tableName: '*' });
55+
} else if (anyTableAccess && (allRead || allAdd || allEdit || allDelete)) {
56+
// Some actions apply to all tables, others are per-table
57+
if (allRead) items.push({ action: 'table:read', tableName: '*' });
58+
if (allAdd) items.push({ action: 'table:add', tableName: '*' });
59+
if (allEdit) items.push({ action: 'table:edit', tableName: '*' });
60+
if (allDelete) items.push({ action: 'table:delete', tableName: '*' });
61+
for (const table of tables) {
62+
const access = table.accessLevel;
63+
if (!allRead && access.visibility) items.push({ action: 'table:read', tableName: table.tableName });
64+
if (!allAdd && access.add) items.push({ action: 'table:add', tableName: table.tableName });
65+
if (!allEdit && access.edit) items.push({ action: 'table:edit', tableName: table.tableName });
66+
if (!allDelete && access.delete) items.push({ action: 'table:delete', tableName: table.tableName });
67+
}
68+
} else {
69+
for (const table of tables) {
70+
const access = table.accessLevel;
71+
if (access.visibility) items.push({ action: 'table:read', tableName: table.tableName });
72+
if (access.add) items.push({ action: 'table:add', tableName: table.tableName });
73+
if (access.edit) items.push({ action: 'table:edit', tableName: table.tableName });
74+
if (access.delete) items.push({ action: 'table:delete', tableName: table.tableName });
75+
}
76+
}
4677
}
4778

4879
return items;
4980
}
5081

82+
export function policyItemsToCedarPolicy(items: CedarPolicyItem[], connectionId: string, groupId: string): string {
83+
const policies: string[] = [];
84+
85+
for (const item of items) {
86+
if (item.action === '*') {
87+
policies.push('permit(\n principal,\n action,\n resource\n);');
88+
return policies.join('\n\n');
89+
}
90+
91+
const actionRef = `RocketAdmin::Action::"${item.action}"`;
92+
let resource: string;
93+
94+
if (item.action.startsWith('table:')) {
95+
if (item.tableName === '*') {
96+
resource = `resource == RocketAdmin::Table::"${connectionId}/*"`;
97+
} else {
98+
resource = `resource == RocketAdmin::Table::"${connectionId}/${item.tableName}"`;
99+
}
100+
} else if (item.action.startsWith('group:')) {
101+
resource = `resource == RocketAdmin::Group::"${groupId}"`;
102+
} else {
103+
resource = `resource == RocketAdmin::Connection::"${connectionId}"`;
104+
}
105+
106+
policies.push(`permit(\n principal,\n action == ${actionRef},\n ${resource}\n);`);
107+
}
108+
109+
return policies.join('\n\n');
110+
}
111+
51112
export function policyItemsToPermissions(
52113
items: CedarPolicyItem[],
53114
connectionId: string,
@@ -95,8 +156,9 @@ export function policyItemsToPermissions(
95156
case 'table:edit':
96157
case 'table:delete': {
97158
if (!item.tableName) break;
98-
const table = result.tables.find((t) => t.tableName === item.tableName);
99-
if (table) {
159+
const targets =
160+
item.tableName === '*' ? result.tables : result.tables.filter((t) => t.tableName === item.tableName);
161+
for (const table of targets) {
100162
table.accessLevel.visibility = true;
101163
switch (item.action) {
102164
case 'table:read':

frontend/src/app/lib/cedar-policy-parser.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ export function parseCedarPolicy(
6464
}
6565
}
6666

67+
// Apply wildcard table permissions to all tables
68+
const wildcardEntry = tableMap.get('*');
69+
6770
// Merge parsed results with full table list
6871
result.tables = availableTables.map((table) => {
6972
if (isFullAccess) {
@@ -78,22 +81,31 @@ export function parseCedarPolicy(
7881
},
7982
};
8083
}
84+
const base = {
85+
visibility: false,
86+
readonly: false,
87+
add: false,
88+
delete: false,
89+
edit: false,
90+
};
91+
if (wildcardEntry) {
92+
base.visibility = base.visibility || wildcardEntry.accessLevel.visibility;
93+
base.readonly = base.readonly || wildcardEntry.accessLevel.readonly;
94+
base.add = base.add || wildcardEntry.accessLevel.add;
95+
base.edit = base.edit || wildcardEntry.accessLevel.edit;
96+
base.delete = base.delete || wildcardEntry.accessLevel.delete;
97+
}
8198
const parsed = tableMap.get(table.tableName);
8299
if (parsed) {
83-
return {
84-
...table,
85-
accessLevel: { ...parsed.accessLevel },
86-
};
100+
base.visibility = base.visibility || parsed.accessLevel.visibility;
101+
base.readonly = base.readonly || parsed.accessLevel.readonly;
102+
base.add = base.add || parsed.accessLevel.add;
103+
base.edit = base.edit || parsed.accessLevel.edit;
104+
base.delete = base.delete || parsed.accessLevel.delete;
87105
}
88106
return {
89107
...table,
90-
accessLevel: {
91-
visibility: false,
92-
readonly: false,
93-
add: false,
94-
delete: false,
95-
edit: false,
96-
},
108+
accessLevel: base,
97109
};
98110
});
99111

0 commit comments

Comments
 (0)