Skip to content

Commit 0e05788

Browse files
authored
feat(calm-hub): hierarchical namespace entitlements (finos#2640)
* feat(calm-hub): allow * as reserved public-access username Update USERNAME_REGEX to accept the literal * character as a valid username, representing public/anyone access. Prevents collision with real OIDC identities. Tests verify * is accepted and ** is rejected. * feat(calm-hub): add getGrantsForUser to UserAccessStore New store method returns a user's own grants plus * (public) grants in a single query, avoiding two round-trips per permission check. Returns an empty list rather than throwing when no grants exist. Implemented for both Mongo and Nitrite backends. * feat(calm-hub): implement hierarchical namespace permission checks Replace flat exact-match logic with ancestor-chain evaluation: - READ: AND-based — every ancestor level must have a grant (user or *) - WRITE/ADMIN: OR-based — any ancestor grant is sufficient Introduces ancestorChain() helper, switches hasNamespaceAccess to use getGrantsForUser for a single store round-trip. Domain checks and global admin are unchanged. Corrects a logic error in the docs example (mark cannot read a child namespace without a grant at that level). * feat(calm-hub): introduce NamespaceService with default public read grant NamespaceResource now delegates all namespace operations through NamespaceService, removing direct store access from the resource. On namespace creation the service inserts a * read grant automatically, making every new namespace publicly readable by default. * feat(calm-hub): add NamespaceMigrationService to backfill * read grants On startup, inserts a * read grant for any namespace that has no grants at all, preserving public visibility after the hierarchical model is deployed. Namespaces with existing grants (even user-specific ones) are left untouched — the admin configured them intentionally and backfilling would grant unintended additional access. Operation is idempotent. * feat(calm-hub): apply hierarchical AND rule to getReadableNamespaces Switch from getUserAccessForUsername (flat, misses * grants) to getGrantsForUser (user + * grants in one call), then apply the same AND ancestor-chain check as canRead: a namespace is included only if every level in its chain has a READ-sufficient grant. Guarantees search results never include namespaces that would return 403 on click-through. * docs(calm-hub): document hierarchical entitlement model Update PERMISSIONS.md as the internal developer reference covering the AND/OR ancestor-chain rules, key classes, and store method distinction. Add an Access Control section to calm-hub.md linking to the new calm-hub-entitlements.md user-facing page. * fix(calm-hub): add * read grants and fix counter in mongo seed data The MongoDB init script bypasses NamespaceService (direct db.namespaces insert), so the auto-inserted * read grant from NamespaceService never ran. NamespaceMigrationService then skips finos/workshop/traderx at startup because they already have named-user grants (demo/demo_admin). Add explicit * read grants (IDs 7-10) for all four seeded namespaces so the seed matches the default-open behaviour of NamespaceService, and bump userAccessStoreCounter from 6 to 11 (was off-by-one: 6 docs with IDs 1-6 existed, but counter was initialised to 6 instead of 7). * fix(calm-hub): skip namespace migration in test mode via LaunchMode check NamespaceMigrationService.onStart() returns immediately when LaunchMode.current() == TEST, preventing it from attempting a MongoDB connection during @QuarkusTest and @testprofile startups where DevServices does not re-patch the connection string after a profile restart. Pure Mockito unit tests are unaffected: LaunchMode.current() returns NORMAL when no Quarkus context is active, so all nine migration unit tests continue to exercise the full migration path. * test(calm-hub): mock UserAccessStore in TestSecurityResponseHeadersShould createNamespace() now also inserts a * read grant via UserAccessStore. The POST /calm/namespaces test in TestSecurityResponseHeadersShould was not mocking UserAccessStore, so the real MongoDB implementation timed out in CI where MongoDB is not available at localhost:27017. * fix(calm-hub): address Copilot PR review comments - NamespaceMigrationService Javadoc: clarify that backfill is skipped when any grant exists (wildcard or named-user), not just wildcard grants - CalmHubPermissionChecker: downgrade per-request auth outcome logs from INFO/WARN to DEBUG to avoid flooding production logs - UserAccessValidator.getReadableNamespaces(): return Optional<Set<String>> where Optional.empty() signals "all namespaces" (no filter). Short-circuit to Optional.empty() when calm.auth.allow-public-read=true or the user holds a GLOBAL admin grant, so search results match canRead() in all cases - SearchTools: unwrap the Optional directly from getReadableNamespaces() - Tests: use containsInAnyOrder for order-independent assertions in getGrantsForUser tests; add bypass-case coverage for UserAccessValidator Rejected: O(ancestors×grants) optimisation (premature at realistic scale) * fix(calm-hub): update SearchResource and test to use Optional<Set<String>> return type getReadableNamespaces() now returns Optional<Set<String>> to distinguish unconstrained access (empty Optional) from no access (Optional of empty set). SearchResource and TestSearchResourceFilteringShould updated to match.
1 parent 0c85722 commit 0e05788

27 files changed

Lines changed: 1320 additions & 153 deletions

calm-hub/PERMISSIONS.md

Lines changed: 96 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,110 @@
1-
# Structure of CalmHub permissions system
1+
# CalmHub Permissions — Developer Reference
22

3-
CalmHub drives its permission system from the in-memory database.
4-
Entitlements are stored as `UserAccess` records.
3+
For user-facing documentation, see [`docs/docs/working-with-calm/calm-hub-entitlements.md`](../docs/docs/working-with-calm/calm-hub-entitlements.md).
54

6-
## Structure of entitlements model
5+
---
76

8-
Entitlements are applied at a per-namespace level, at domain level for control requirements and configurations.
7+
## Storage model
98

10-
The available actions are the following.
9+
Entitlements are stored as `UserAccess` records (see `domain/UserAccess.java`). Each record has:
10+
- `username` — a real username from the OIDC provider, or `*` for the public-access wildcard
11+
- `permission``read`, `write`, or `admin`
12+
- `namespace` — the namespace this grant applies to (mutually exclusive with `domain`)
13+
- `domain` — the control domain this grant applies to (mutually exclusive with `namespace`)
1114

12-
| Action | Description |
13-
|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
14-
| `read` | Can read any documents of that type in the namespace. |
15-
| `write` | Can write any documents of that type in the namespace. This includes deleting them. Note that by default resources in CalmHub are immutable, so this usually means 'create' only. |
16-
| `admin` | Can do anything to all resource types, and also grant entitlements to other users in the namespace. |
15+
The `*` wildcard is a valid username value (per `USERNAME_REGEX` in `ResourceValidationConstants`). It represents any user and is evaluated by the same logic as named user grants.
1716

18-
For example, `read` means the user can read all resources in that NS.
17+
---
1918

20-
Please note that each entitlement implies all previous levels - i.e. `write` implies `read`.
21-
`admin` implies `read` and `write` on all resource types.
19+
## Permission levels
2220

23-
## Global admin
21+
| Permission | Implies |
22+
|------------|---------|
23+
| `read` ||
24+
| `write` | `read` |
25+
| `admin` | `read`, `write` |
2426

25-
Some resources aren't tied to any one namespace.
26-
Creating namespaces and managing core schemas requires the `admin` role, with the namespace `GLOBAL` in the database.
27+
---
2728

28-
**NOTE**: Global admin also gives you the right to manage domains.
29-
There's only one notion of global admin.
29+
## Hierarchical namespace rules
3030

31-
## Global READ mode
31+
Namespaces use `.` as a separator (`org`, `org.ab`, `org.ab.cd`). The ancestor chain for a namespace is the list of all prefixes, inclusive of the namespace itself.
3232

33-
It's possible to configure CalmHub to grant `read` to all users by default.
33+
### READ — AND across ancestor chain
3434

35-
To do this, set the property `calm.auth.allow-public-read=true`.
36-
By default this property is `false`.
35+
```
36+
canRead(username, namespace):
37+
grants = store.getGrantsForUser(username) // user grants + * grants in one query
38+
ancestors = ancestorChain(namespace) // ["org", "org.ab", "org.ab.cd"] for "org.ab.cd"
39+
return ancestors.ALL(level →
40+
grants.ANY(g → g.namespace == level && permissionSufficient(g, READ)))
41+
```
3742

43+
**Every** level must have a matching grant. This allows a parent to be public while a child is restricted.
44+
45+
### WRITE / ADMIN — OR across ancestor chain
46+
47+
```
48+
canWrite(username, namespace):
49+
grants = store.getGrantsForUser(username)
50+
ancestors = ancestorChain(namespace)
51+
return ancestors.ANY(level →
52+
grants.ANY(g → g.namespace == level && permissionSufficient(g, WRITE)))
53+
```
54+
55+
**Any** ancestor (including the namespace itself) with a sufficient grant is enough. Grants at a parent cascade to all descendants for write/admin purposes.
56+
57+
### `permissionSufficient`
58+
59+
| Requested | Sufficient grant |
60+
|-----------|-----------------|
61+
| READ | `read`, `write`, or `admin` |
62+
| WRITE | `write` or `admin` |
63+
| ADMIN | `admin` only |
64+
65+
---
66+
67+
## Key components
68+
69+
| Class | Role |
70+
|-------|------|
71+
| `CalmHubPermissionChecker` | Implements the AND/OR hierarchical logic; calls `getGrantsForUser` |
72+
| `UserAccessStore` (interface) | `getGrantsForUser(username)` returns user + `*` grants in one query |
73+
| `MongoUserAccessStore` | Mongo implementation of `getGrantsForUser` |
74+
| `NitriteUserAccessStore` | Nitrite implementation of `getGrantsForUser` |
75+
| `UserAccessValidator` | `getReadableNamespaces(username)` — exact ancestor-chain filter used by search |
76+
| `NamespaceService` | Orchestrates namespace creation + automatic `* read` grant insertion |
77+
| `NamespaceMigrationService` | StartupEvent observer; backfills `* read` grants on pre-existing namespaces |
78+
79+
---
80+
81+
## Special bypasses
82+
83+
### `allow-public-read` config flag
84+
85+
`calm.auth.allow-public-read=true` is a global bypass in `CalmHubPermissionChecker.canRead`. It short-circuits all namespace checks. Intended for fully-open deployments; default is `false`.
86+
87+
### GLOBAL admin
88+
89+
A user with `admin` on `GLOBAL` bypasses all namespace-level permission checks via `hasGlobalAdmin`. Domain access is also granted. `GLOBAL` is not part of the namespace hierarchy.
90+
91+
---
92+
93+
## Default-open namespace behaviour
94+
95+
When a namespace is created via `NamespaceService`, a `UserAccess("*", read, namespace)` record is inserted automatically. This keeps the hub open by default.
96+
97+
To restrict a namespace: delete the `* read` record. The AND rule for child namespaces means restriction cascades automatically.
98+
99+
---
100+
101+
## `getGrantsForUser` vs `getUserAccessForUsername`
102+
103+
- **`getGrantsForUser(username)`** — returns grants where `username = :username OR username = '*'`. Used by the permission checker (single round-trip per request).
104+
- **`getUserAccessForUsername(username)`** — returns grants for exactly one username. Used by admin/management endpoints (`UserAccessResource`, `UserAccessValidator`) that need to inspect a single user's grants without `*` mixed in.
105+
106+
---
107+
108+
## Domain-scoped access
109+
110+
`hasDomainAccess`, `canReadByDomain`, etc. are **not** hierarchical — domain access remains flat. `UserAccess` records have either `namespace` or `domain` set, never both.

calm-hub/mongo/init-mongo.js

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,9 @@ if (db.counters.countDocuments({ _id: "flowStoreCounter" }) === 0) {
8484
if (db.counters.countDocuments({ _id: "userAccessStoreCounter" }) === 0) {
8585
db.counters.insertOne({
8686
_id: "userAccessStoreCounter",
87-
sequence_value: 6
87+
sequence_value: 11
8888
});
89-
logSuccess("Initialized userAccessStoreCounter with sequence_value 6");
89+
logSuccess("Initialized userAccessStoreCounter with sequence_value 11");
9090
} else {
9191
logSkip("userAccessStoreCounter already exists, no initialization needed");
9292
}
@@ -2532,9 +2532,33 @@ if (db.userAccess.countDocuments() === 0) {
25322532
"username": "demo",
25332533
"permission": "read",
25342534
"namespace": "workshop"
2535+
},
2536+
{
2537+
"userAccessId": NumberInt(7),
2538+
"username": "*",
2539+
"permission": "read",
2540+
"namespace": "finos"
2541+
},
2542+
{
2543+
"userAccessId": NumberInt(8),
2544+
"username": "*",
2545+
"permission": "read",
2546+
"namespace": "workshop"
2547+
},
2548+
{
2549+
"userAccessId": NumberInt(9),
2550+
"username": "*",
2551+
"permission": "read",
2552+
"namespace": "traderx"
2553+
},
2554+
{
2555+
"userAccessId": NumberInt(10),
2556+
"username": "*",
2557+
"permission": "read",
2558+
"namespace": "ai-governance-v2"
25352559
}
25362560
]);
2537-
logSuccess("Initialized user access for demo_admin and demo users");
2561+
logSuccess("Initialized user access for demo_admin, demo, and * (public read) users");
25382562
} else {
25392563
logSkip("User access already initialized, skipping...");
25402564
}

calm-hub/src/main/java/org/finos/calm/mcp/tools/SearchTools.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ private Optional<Set<String>> resolveReadableNamespaces() {
102102
if (!authEnabled || !userAccessValidatorInstance.isResolvable()) {
103103
return Optional.empty();
104104
}
105-
return Optional.of(userAccessValidatorInstance.get().getReadableNamespaces(identity.getPrincipal().getName()));
105+
return userAccessValidatorInstance.get().getReadableNamespaces(identity.getPrincipal().getName());
106106
}
107107

108108
private static Map<String, List<SearchResult>> toGroupMap(GroupedSearchResults results) {

calm-hub/src/main/java/org/finos/calm/resources/NamespaceResource.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import org.finos.calm.domain.namespaces.NamespaceInfo;
1717
import org.finos.calm.security.CalmHubPermissionChecker;
1818
import org.finos.calm.security.CalmHubScopes;
19-
import org.finos.calm.store.NamespaceStore;
19+
import org.finos.calm.services.NamespaceService;
2020

2121
import java.net.URI;
2222
import java.net.URISyntaxException;
@@ -25,11 +25,11 @@
2525
@Path("/api/calm/namespaces")
2626
public class NamespaceResource {
2727

28-
private final NamespaceStore namespaceStore;
28+
private final NamespaceService namespaceService;
2929

3030
@Inject
31-
public NamespaceResource(NamespaceStore store) {
32-
this.namespaceStore = store;
31+
public NamespaceResource(NamespaceService namespaceService) {
32+
this.namespaceService = namespaceService;
3333
}
3434

3535
@GET
@@ -39,7 +39,7 @@ public NamespaceResource(NamespaceStore store) {
3939
)
4040
@Authenticated
4141
public ValueWrapper<NamespaceInfo> namespaces() {
42-
return new ValueWrapper<>(namespaceStore.getNamespaces());
42+
return new ValueWrapper<>(namespaceService.getNamespaces());
4343
}
4444

4545
@POST
@@ -62,7 +62,7 @@ public Response createNamespace(@Valid @NotNull(message = "Request must not be n
6262
}
6363

6464
try {
65-
namespaceStore.createNamespace(name, description);
65+
namespaceService.createNamespace(name, description);
6666
} catch (NamespaceAlreadyExistsException e) {
6767
return Response.status(Response.Status.CONFLICT)
6868
.entity("{\"error\":\"Namespace already exists\"}")

calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
public class ResourceValidationConstants {
77
public static final String NAMESPACE_REGEX = "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)*$";
88
public static final String NAMESPACE_MESSAGE = "namespace must match pattern '^[A-Za-z0-9-]+([.][A-Za-z0-9-]+)*$'";
9-
// dashes, dots, usernames, upper/lowercase letters and numbers.
10-
public static final String USERNAME_REGEX = "^[A-Za-z0-9._-]+$";
11-
public static final String USERNAME_MESSAGE = "username must match pattern '^[A-Za-z0-9._-]+$'";
9+
// dashes, dots, usernames, upper/lowercase letters, numbers, and * for the public-access wildcard.
10+
public static final String USERNAME_REGEX = "^(\\*|[A-Za-z0-9._-]+)$";
11+
public static final String USERNAME_MESSAGE = "username must match pattern '^(\\*|[A-Za-z0-9._-]+)$'";
1212
public static final String DOMAIN_REGEX = "^[A-Za-z0-9-]+$";
1313
public static final String DOMAIN_MESSAGE = "domain name must match pattern '^[A-Za-z0-9-]+$'";
1414
public static final String VERSION_REGEX = "^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)$";

calm-hub/src/main/java/org/finos/calm/resources/SearchResource.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ private Optional<Set<String>> resolveReadableNamespaces() {
8181
if (!authEnabled || !userAccessValidatorInstance.isResolvable()) {
8282
return Optional.empty();
8383
}
84-
return Optional.of(userAccessValidatorInstance.get().getReadableNamespaces(identity.getPrincipal().getName()));
84+
return userAccessValidatorInstance.get().getReadableNamespaces(identity.getPrincipal().getName());
8585
}
8686

8787
/**

calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import org.slf4j.Logger;
1313
import org.slf4j.LoggerFactory;
1414

15+
import java.util.ArrayList;
16+
import java.util.List;
1517
import java.util.function.Predicate;
1618

1719

@@ -102,9 +104,9 @@ public boolean hasGlobalAdmin(SecurityIdentity identity) {
102104
&& permissionSufficient(grant, UserAction.ADMIN));
103105

104106
if (granted) {
105-
logger.info("User [{}] AUTHORIZED for GLOBAL admin privileges", username);
107+
logger.debug("User [{}] AUTHORIZED for GLOBAL admin privileges", username);
106108
} else {
107-
logger.warn("User [{}] DENIED for GLOBAL admin privileges", username);
109+
logger.debug("User [{}] DENIED for GLOBAL admin privileges", username);
108110
}
109111
return granted;
110112
} catch (UserAccessNotFoundException e) {
@@ -114,8 +116,45 @@ public boolean hasGlobalAdmin(SecurityIdentity identity) {
114116
}
115117

116118
private boolean hasNamespaceAccess(SecurityIdentity identity, String namespace, UserAction action) {
117-
return hasAccess(identity, "namespace", namespace, action,
118-
grant -> namespace != null && namespace.equals(grant.getNamespace()));
119+
if (namespace == null) return false;
120+
String username = identity.getPrincipal().getName();
121+
logger.debug("Checking namespace access for user [{}] on namespace [{}] action=[{}]", username, namespace, action);
122+
123+
List<UserAccess> grants = userAccessStore.getGrantsForUser(username);
124+
List<String> ancestors = ancestorChain(namespace);
125+
126+
boolean result = action == UserAction.READ
127+
? allAncestorsHaveGrant(ancestors, grants, action)
128+
: anyAncestorHasGrant(ancestors, grants, action);
129+
130+
if (result) {
131+
logger.debug("User [{}] AUTHORIZED for [{}] in namespace [{}]", username, action, namespace);
132+
} else {
133+
logger.debug("User [{}] DENIED for [{}] in namespace [{}]", username, action, namespace);
134+
}
135+
return result;
136+
}
137+
138+
private boolean allAncestorsHaveGrant(List<String> ancestors, List<UserAccess> grants, UserAction action) {
139+
return ancestors.stream().allMatch(level ->
140+
grants.stream().anyMatch(g -> level.equals(g.getNamespace()) && permissionSufficient(g, action)));
141+
}
142+
143+
private boolean anyAncestorHasGrant(List<String> ancestors, List<UserAccess> grants, UserAction action) {
144+
return ancestors.stream().anyMatch(level ->
145+
grants.stream().anyMatch(g -> level.equals(g.getNamespace()) && permissionSufficient(g, action)));
146+
}
147+
148+
static List<String> ancestorChain(String namespace) {
149+
String[] parts = namespace.split("\\.");
150+
List<String> chain = new ArrayList<>(parts.length);
151+
StringBuilder sb = new StringBuilder();
152+
for (String part : parts) {
153+
if (!sb.isEmpty()) sb.append('.');
154+
sb.append(part);
155+
chain.add(sb.toString());
156+
}
157+
return chain;
119158
}
120159

121160
private boolean hasDomainAccess(SecurityIdentity identity, String domain, UserAction action) {
@@ -132,9 +171,9 @@ private boolean hasAccess(SecurityIdentity identity, String scopeType, String sc
132171
boolean result = userAccessStore.getUserAccessForUsername(username).stream()
133172
.anyMatch(grant -> grantMatcher.test(grant) && permissionSufficient(grant, action));
134173
if (result) {
135-
logger.info("User [{}] AUTHORIZED for [{}] in {} [{}]", username, action, scopeType, scopeValue);
174+
logger.debug("User [{}] AUTHORIZED for [{}] in {} [{}]", username, action, scopeType, scopeValue);
136175
} else {
137-
logger.warn("User [{}] DENIED for [{}] in {} [{}]", username, action, scopeType, scopeValue);
176+
logger.debug("User [{}] DENIED for [{}] in {} [{}]", username, action, scopeType, scopeValue);
138177
}
139178
return result;
140179
} catch (UserAccessNotFoundException e) {

0 commit comments

Comments
 (0)