feat(auth): WorkflowRoles configurable claim path + ordinis:admin super-role
User report: token has roles в resource_access.ordinis.roles (client-scoped),
backend WorkflowRoles читал hardcoded realm_access.roles → forbidden_role
для всех reviewer actions.
Backend:
- WorkflowRoles: depends OrdinisAuthProperties → reads via rolesClaim path
(же что ScopeContext). Default realm_access.roles, на staging/prod env
var ORDINIS_AUTH_ROLES_CLAIM=resource_access.ordinis.roles (уже wired
в helm helper).
- Nested claim path resolver — mirror ScopeContext.readClaimByPath
- New super-role ADMIN ('ordinis:admin') — holder автоматически проходит
любой workflow check без явного назначения отдельных ролей. Convenience
для small teams.
Frontend (usePermissions.ts):
- tokenRoles() читает оба claim path (realm_access.roles +
resource_access.ordinis.roles) и объединяет — UI gate работает
независимо от backend config
- hasRole() recognizes 'ordinis:admin' как super-role override
- ROLE_ADMIN exported
Docs:
- docs/operator/rbac.md: добавлен ordinis:admin row, JWT claim path note,
Admin profile updated (super-role vs composite explanation)
This commit is contained in:
+69
-15
@@ -1,5 +1,6 @@
|
||||
package cloud.nstart.terravault.ordinis.restapi.auth;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.auth.OrdinisAuthProperties;
|
||||
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -10,6 +11,7 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAut
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -35,8 +37,12 @@ import java.util.Set;
|
||||
* {@code self_approve_forbidden} (maker != reviewer) как primary defence
|
||||
* для maker-checker invariant.
|
||||
*
|
||||
* <p>JWT источник: {@code realm_access.roles} claim (Keycloak realm roles).
|
||||
* Совместимо с {@code ScopeContext} JWT parsing pattern.
|
||||
* <p>JWT источник: configurable claim path через
|
||||
* {@link OrdinisAuthProperties#rolesClaim()} — тот же что у
|
||||
* {@code ScopeContext}. Default {@code realm_access.roles} (Keycloak realm
|
||||
* roles). Можно настроить на {@code resource_access.ordinis.roles} если
|
||||
* Keycloak realm использует client-scoped roles (mapped к {@code ordinis}
|
||||
* client).
|
||||
*
|
||||
* <p><b>Migration note (Phase 1d enforcement):</b> Keycloak realm должен
|
||||
* иметь созданные роли, и users должны их получить. Иначе все decide /
|
||||
@@ -59,18 +65,44 @@ public class WorkflowRoles {
|
||||
/** Approve / reject record draft (Approval Workflow v2). */
|
||||
public static final String RECORD_REVIEWER = "ordinis:record:reviewer";
|
||||
|
||||
/** Read realm_access.roles из current request JWT. Anonymous → empty. */
|
||||
/**
|
||||
* Super-role: holder автоматически проходит любой role check без явного
|
||||
* назначения отдельных workflow roles. Convenience для small teams где
|
||||
* один-два user'а имеют full access — assign one role вместо трёх.
|
||||
*
|
||||
* <p>Composite role в Keycloak — лучше; super-role override — fallback
|
||||
* для cases где composite ещё не настроен.
|
||||
*/
|
||||
public static final String ADMIN = "ordinis:admin";
|
||||
|
||||
private final OrdinisAuthProperties props;
|
||||
|
||||
public WorkflowRoles(OrdinisAuthProperties props) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
/** Test ctor — без OrdinisAuthProperties (defaults to realm_access.roles). */
|
||||
WorkflowRoles() {
|
||||
this.props = null;
|
||||
}
|
||||
|
||||
/** Reads roles из configured claim path. Anonymous → empty. */
|
||||
public Set<String> currentRoles() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (!(auth instanceof JwtAuthenticationToken jwt)) {
|
||||
return Set.of();
|
||||
}
|
||||
return extractRoles(jwt.getToken());
|
||||
return extractRoles(jwt.getToken(), rolesClaimPath());
|
||||
}
|
||||
|
||||
/** True если у actor'а есть указанная realm role. */
|
||||
/**
|
||||
* True если у actor'а есть указанная workflow role ИЛИ super-role
|
||||
* {@link #ADMIN}. Admin грантит full workflow access без явного
|
||||
* назначения отдельных reviewer/publisher ролей.
|
||||
*/
|
||||
public boolean hasRole(String role) {
|
||||
return currentRoles().contains(role);
|
||||
Set<String> roles = currentRoles();
|
||||
return roles.contains(role) || roles.contains(ADMIN);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,23 +111,26 @@ public class WorkflowRoles {
|
||||
*/
|
||||
public void requireRole(String role) {
|
||||
if (!hasRole(role)) {
|
||||
log.debug("Access denied: missing required realm role '{}'", role);
|
||||
log.debug("Access denied: missing required workflow role '{}' at claim path '{}'",
|
||||
role, rolesClaimPath());
|
||||
throw OrdinisException.forbidden(
|
||||
"forbidden_role",
|
||||
"Требуется realm role: " + role);
|
||||
}
|
||||
}
|
||||
|
||||
static Set<String> extractRoles(Jwt token) {
|
||||
Object realmAccessObj = token.getClaim("realm_access");
|
||||
if (!(realmAccessObj instanceof Map<?, ?> realmAccess)) {
|
||||
private String rolesClaimPath() {
|
||||
return props != null ? props.rolesClaim() : "realm_access.roles";
|
||||
}
|
||||
|
||||
/** Reads JWT claim по nested path (e.g. "realm_access.roles" or
|
||||
* "resource_access.ordinis.roles") и возвращает roles как Set<String>. */
|
||||
static Set<String> extractRoles(Jwt token, String path) {
|
||||
Object current = readClaimByPath(token, path);
|
||||
if (!(current instanceof Collection<?> rolesCol)) {
|
||||
return Set.of();
|
||||
}
|
||||
Object rolesObj = realmAccess.get("roles");
|
||||
if (!(rolesObj instanceof Collection<?> rolesCol)) {
|
||||
return Set.of();
|
||||
}
|
||||
java.util.LinkedHashSet<String> out = new java.util.LinkedHashSet<>();
|
||||
LinkedHashSet<String> out = new LinkedHashSet<>();
|
||||
for (Object o : rolesCol) {
|
||||
if (o instanceof String s) {
|
||||
out.add(s);
|
||||
@@ -103,4 +138,23 @@ public class WorkflowRoles {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Default-path overload для тестов и backward compat. */
|
||||
static Set<String> extractRoles(Jwt token) {
|
||||
return extractRoles(token, "realm_access.roles");
|
||||
}
|
||||
|
||||
/** Nested JWT claim resolver. Mirror {@code ScopeContext.readClaimByPath}. */
|
||||
private static Object readClaimByPath(Jwt token, String path) {
|
||||
String[] parts = path.split("\\.");
|
||||
Object current = token.getClaim(parts[0]);
|
||||
for (int i = 1; i < parts.length && current != null; i++) {
|
||||
if (current instanceof Map<?, ?> m) {
|
||||
current = m.get(parts[i]);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user