Skip to content

Commit ee4c037

Browse files
akoclaude
andcommitted
feat: GRANT/REVOKE ACCESS on PUBLISHED REST SERVICE (#162)
Adds module-role access control for published REST services, mirroring the existing OData service GRANT/REVOKE syntax. The REST metamodel stores this in the AllowedRoles BSON field (vs OData's AllowedModuleRoles), so a separate writer entrypoint is introduced. DESCRIBE now emits a trailing GRANT statement when AllowedRoles is non-empty so the output remains a re-executable roundtrip. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9603c2b commit ee4c037

File tree

18 files changed

+9900
-9126
lines changed

18 files changed

+9900
-9126
lines changed

cmd/mxcli/help_topics/rest.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ CREATE / DROP PUBLISHED REST SERVICE
3737

3838
Properties: Path (required), Version, ServiceName, Folder
3939
HTTP methods: GET, POST, PUT, DELETE, PATCH
40+
41+
GRANT / REVOKE ACCESS
42+
---------------------
43+
44+
GRANT ACCESS ON PUBLISHED REST SERVICE Module.MyAPI
45+
TO Module.User, Module.Admin;
46+
47+
REVOKE ACCESS ON PUBLISHED REST SERVICE Module.MyAPI
48+
FROM Module.User;
49+
4050
Modifiers: DEPRECATED, IMPORT MAPPING, EXPORT MAPPING, COMMIT
4151
Operation paths: empty '' for root, '{name}' for path params.
4252
Do NOT start/end with /.

docs/01-project/MDL_QUICK_REFERENCE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,8 @@ CREATE OR REPLACE NAVIGATION Responsive
445445
| Create service | See below | |
446446
| Create or replace | `CREATE OR REPLACE PUBLISHED REST SERVICE ...` | Replaces existing service |
447447
| Drop service | `DROP PUBLISHED REST SERVICE Module.Name;` | |
448+
| Grant access | `GRANT ACCESS ON PUBLISHED REST SERVICE Module.Name TO Module.Role, ...;` | Adds module roles to AllowedRoles |
449+
| Revoke access | `REVOKE ACCESS ON PUBLISHED REST SERVICE Module.Name FROM Module.Role, ...;` | |
448450

449451
```sql
450452
CREATE PUBLISHED REST SERVICE Module.MyAPI (

mdl-examples/doctype-tests/22-published-rest-service-examples.mdl

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,23 @@ SHOW PUBLISHED REST SERVICES;
124124
SHOW PUBLISHED REST SERVICES IN PrsTest;
125125

126126
-- ############################################################################
127-
-- PART 6: DROP
127+
-- PART 6: GRANT / REVOKE ACCESS
128+
-- ############################################################################
129+
--
130+
-- Grant module roles access to a published REST service. The roles must
131+
-- already exist in the same module's security model.
132+
133+
CREATE MODULE ROLE PrsTest.User;
134+
CREATE MODULE ROLE PrsTest.Admin;
135+
136+
GRANT ACCESS ON PUBLISHED REST SERVICE PrsTest.OrderAPI TO PrsTest.User, PrsTest.Admin;
137+
DESCRIBE PUBLISHED REST SERVICE PrsTest.OrderAPI;
138+
139+
REVOKE ACCESS ON PUBLISHED REST SERVICE PrsTest.OrderAPI FROM PrsTest.User;
140+
DESCRIBE PUBLISHED REST SERVICE PrsTest.OrderAPI;
141+
142+
-- ############################################################################
143+
-- PART 7: DROP
128144
-- ############################################################################
129145

130146
DROP PUBLISHED REST SERVICE PrsTest.OrderAPI;

mdl/ast/ast_security.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,22 @@ type RevokeODataServiceAccessStmt struct {
150150

151151
func (s *RevokeODataServiceAccessStmt) isStatement() {}
152152

153+
// GrantPublishedRestServiceAccessStmt represents: GRANT ACCESS ON PUBLISHED REST SERVICE Module.Svc TO role1, role2
154+
type GrantPublishedRestServiceAccessStmt struct {
155+
Service QualifiedName
156+
Roles []QualifiedName
157+
}
158+
159+
func (s *GrantPublishedRestServiceAccessStmt) isStatement() {}
160+
161+
// RevokePublishedRestServiceAccessStmt represents: REVOKE ACCESS ON PUBLISHED REST SERVICE Module.Svc FROM role1, role2
162+
type RevokePublishedRestServiceAccessStmt struct {
163+
Service QualifiedName
164+
Roles []QualifiedName
165+
}
166+
167+
func (s *RevokePublishedRestServiceAccessStmt) isStatement() {}
168+
153169
// AlterProjectSecurityStmt represents ALTER PROJECT SECURITY commands.
154170
type AlterProjectSecurityStmt struct {
155171
// SecurityLevel is set for ALTER PROJECT SECURITY LEVEL (PRODUCTION|PROTOTYPE|OFF)

mdl/executor/cmd_published_rest.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ func (e *Executor) describePublishedRestService(name ast.QualifiedName) error {
141141
}
142142
fmt.Fprintln(e.output, "/")
143143

144+
// Emit GRANT statements for any module roles with access.
145+
if len(svc.AllowedRoles) > 0 {
146+
fmt.Fprintf(e.output, "\nGRANT ACCESS ON PUBLISHED REST SERVICE %s.%s TO %s;\n",
147+
modName, svc.Name, strings.Join(svc.AllowedRoles, ", "))
148+
}
149+
144150
return nil
145151
}
146152

mdl/executor/cmd_security_write.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1199,6 +1199,126 @@ func (e *Executor) execRevokeODataServiceAccess(s *ast.RevokeODataServiceAccessS
11991199
return fmt.Errorf("published OData service not found: %s.%s", s.Service.Module, s.Service.Name)
12001200
}
12011201

1202+
// ============================================================================
1203+
// GRANT/REVOKE ACCESS ON PUBLISHED REST SERVICE
1204+
// ============================================================================
1205+
1206+
// execGrantPublishedRestServiceAccess handles GRANT ACCESS ON PUBLISHED REST SERVICE Module.Svc TO roles.
1207+
func (e *Executor) execGrantPublishedRestServiceAccess(s *ast.GrantPublishedRestServiceAccessStmt) error {
1208+
if e.writer == nil {
1209+
return fmt.Errorf("not connected to a project in write mode")
1210+
}
1211+
1212+
h, err := e.getHierarchy()
1213+
if err != nil {
1214+
return fmt.Errorf("failed to build hierarchy: %w", err)
1215+
}
1216+
1217+
services, err := e.reader.ListPublishedRestServices()
1218+
if err != nil {
1219+
return fmt.Errorf("failed to list published REST services: %w", err)
1220+
}
1221+
1222+
for _, svc := range services {
1223+
modID := h.FindModuleID(svc.ContainerID)
1224+
modName := h.GetModuleName(modID)
1225+
if modName != s.Service.Module || svc.Name != s.Service.Name {
1226+
continue
1227+
}
1228+
1229+
// Validate all roles exist
1230+
for _, role := range s.Roles {
1231+
if err := e.validateModuleRole(role); err != nil {
1232+
return err
1233+
}
1234+
}
1235+
1236+
// Merge new roles with existing (skip duplicates)
1237+
existing := make(map[string]bool)
1238+
var merged []string
1239+
for _, r := range svc.AllowedRoles {
1240+
existing[r] = true
1241+
merged = append(merged, r)
1242+
}
1243+
var added []string
1244+
for _, role := range s.Roles {
1245+
qn := role.Module + "." + role.Name
1246+
if !existing[qn] {
1247+
merged = append(merged, qn)
1248+
added = append(added, qn)
1249+
}
1250+
}
1251+
1252+
if err := e.writer.UpdatePublishedRestServiceRoles(svc.ID, merged); err != nil {
1253+
return fmt.Errorf("failed to update published REST service access: %w", err)
1254+
}
1255+
1256+
if len(added) == 0 {
1257+
fmt.Fprintf(e.output, "All specified roles already have access on published REST service %s.%s\n", modName, svc.Name)
1258+
} else {
1259+
fmt.Fprintf(e.output, "Granted access on published REST service %s.%s to %s\n", modName, svc.Name, strings.Join(added, ", "))
1260+
}
1261+
return nil
1262+
}
1263+
1264+
return fmt.Errorf("published REST service not found: %s.%s", s.Service.Module, s.Service.Name)
1265+
}
1266+
1267+
// execRevokePublishedRestServiceAccess handles REVOKE ACCESS ON PUBLISHED REST SERVICE Module.Svc FROM roles.
1268+
func (e *Executor) execRevokePublishedRestServiceAccess(s *ast.RevokePublishedRestServiceAccessStmt) error {
1269+
if e.writer == nil {
1270+
return fmt.Errorf("not connected to a project in write mode")
1271+
}
1272+
1273+
h, err := e.getHierarchy()
1274+
if err != nil {
1275+
return fmt.Errorf("failed to build hierarchy: %w", err)
1276+
}
1277+
1278+
services, err := e.reader.ListPublishedRestServices()
1279+
if err != nil {
1280+
return fmt.Errorf("failed to list published REST services: %w", err)
1281+
}
1282+
1283+
for _, svc := range services {
1284+
modID := h.FindModuleID(svc.ContainerID)
1285+
modName := h.GetModuleName(modID)
1286+
if modName != s.Service.Module || svc.Name != s.Service.Name {
1287+
continue
1288+
}
1289+
1290+
// Build set of roles to remove
1291+
toRemove := make(map[string]bool)
1292+
for _, role := range s.Roles {
1293+
toRemove[role.Module+"."+role.Name] = true
1294+
}
1295+
1296+
// Filter out removed roles
1297+
var remaining []string
1298+
var removed []string
1299+
for _, r := range svc.AllowedRoles {
1300+
if toRemove[r] {
1301+
removed = append(removed, r)
1302+
} else {
1303+
remaining = append(remaining, r)
1304+
}
1305+
}
1306+
1307+
if err := e.writer.UpdatePublishedRestServiceRoles(svc.ID, remaining); err != nil {
1308+
return fmt.Errorf("failed to update published REST service access: %w", err)
1309+
}
1310+
1311+
if len(removed) == 0 {
1312+
fmt.Fprintf(e.output, "None of the specified roles had access on published REST service %s.%s\n", modName, svc.Name)
1313+
} else {
1314+
fmt.Fprintf(e.output, "Revoked access on published REST service %s.%s from %s\n", modName, svc.Name, strings.Join(removed, ", "))
1315+
}
1316+
return nil
1317+
}
1318+
1319+
return fmt.Errorf("published REST service not found: %s.%s", s.Service.Module, s.Service.Name)
1320+
}
1321+
12021322
// execUpdateSecurity handles UPDATE SECURITY [IN Module].
12031323
func (e *Executor) execUpdateSecurity(s *ast.UpdateSecurityStmt) error {
12041324
if e.writer == nil {

mdl/executor/executor_dispatch.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,10 @@ func (e *Executor) executeInner(stmt ast.Statement) error {
208208
return e.execGrantODataServiceAccess(s)
209209
case *ast.RevokeODataServiceAccessStmt:
210210
return e.execRevokeODataServiceAccess(s)
211+
case *ast.GrantPublishedRestServiceAccessStmt:
212+
return e.execGrantPublishedRestServiceAccess(s)
213+
case *ast.RevokePublishedRestServiceAccessStmt:
214+
return e.execRevokePublishedRestServiceAccess(s)
211215

212216
// Query statements
213217
case *ast.ShowStmt:

mdl/executor/stmt_summary.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ func stmtSummary(stmt ast.Statement) string {
121121
return fmt.Sprintf("GRANT ACCESS ON ODATA SERVICE %s", s.Service)
122122
case *ast.RevokeODataServiceAccessStmt:
123123
return fmt.Sprintf("REVOKE ACCESS ON ODATA SERVICE %s", s.Service)
124+
case *ast.GrantPublishedRestServiceAccessStmt:
125+
return fmt.Sprintf("GRANT ACCESS ON PUBLISHED REST SERVICE %s", s.Service)
126+
case *ast.RevokePublishedRestServiceAccessStmt:
127+
return fmt.Sprintf("REVOKE ACCESS ON PUBLISHED REST SERVICE %s", s.Service)
124128

125129
// Image Collection
126130
case *ast.CreateImageCollectionStmt:

mdl/grammar/MDLParser.g4

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,8 @@ securityStatement
342342
| revokeWorkflowAccessStatement
343343
| grantODataServiceAccessStatement
344344
| revokeODataServiceAccessStatement
345+
| grantPublishedRestServiceAccessStatement
346+
| revokePublishedRestServiceAccessStatement
345347
| alterProjectSecurityStatement
346348
| dropDemoUserStatement
347349
| updateSecurityStatement
@@ -413,6 +415,14 @@ revokeODataServiceAccessStatement
413415
: REVOKE ACCESS ON ODATA SERVICE qualifiedName FROM moduleRoleList
414416
;
415417

418+
grantPublishedRestServiceAccessStatement
419+
: GRANT ACCESS ON PUBLISHED REST SERVICE qualifiedName TO moduleRoleList
420+
;
421+
422+
revokePublishedRestServiceAccessStatement
423+
: REVOKE ACCESS ON PUBLISHED REST SERVICE qualifiedName FROM moduleRoleList
424+
;
425+
416426
alterProjectSecurityStatement
417427
: ALTER PROJECT SECURITY LEVEL (PRODUCTION | PROTOTYPE | OFF)
418428
| ALTER PROJECT SECURITY DEMO USERS (ON | OFF)

mdl/grammar/parser/MDLParser.interp

Lines changed: 3 additions & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)