RbacSpec.java
package net.hostsharing.hsadminng.rbac.generator;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import org.reflections.Reflections;
import org.reflections.scanners.TypeAnnotationsScanner;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import jakarta.validation.constraints.NotNull;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.file.Path;
import java.util.*;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import static java.lang.reflect.Modifier.isStatic;
import static java.util.Arrays.asList;
import static java.util.Arrays.stream;
import static java.util.Collections.max;
import static java.util.Optional.ofNullable;
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.ColumnValue.usingDefaultCase;
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Nullable.NOT_NULL;
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.RbacGrantDefinition.GrantType.PERM_TO_ROLE;
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.RbacGrantDefinition.GrantType.ROLE_TO_ROLE;
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.RbacSubjectReference.UserRole.CREATOR;
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.SQL.Part.AUTO_FETCH;
import static org.apache.commons.collections4.SetUtils.hashSet;
import static org.apache.commons.lang3.StringUtils.capitalize;
import static org.apache.commons.lang3.StringUtils.uncapitalize;
@Getter
public class RbacSpec {
public static final String GLOBAL = "rbac.global";
public static final String OUTPUT_BASEDIR = "src/main/resources/db/changelog";
public static Consumer<RbacSpec.RbacRoleDefinition> WITHOUT_IMPLICIT_GRANTS = with -> {};
private final EntityAlias rootEntityAlias;
private final Set<RbacSubjectReference> userDefs = new LinkedHashSet<>();
private final Set<RbacRoleDefinition> roleDefs = new LinkedHashSet<>();
private final Set<RbacPermissionDefinition> permDefs = new LinkedHashSet<>();
private final Map<String, EntityAlias> entityAliases = new HashMap<>() {
@Override
public EntityAlias put(final String key, final EntityAlias value) {
if (containsKey(key)) {
throw new IllegalArgumentException("duplicate entityAlias: " + key);
}
return super.put(key, value);
}
};
private final Set<String> updatableColumns = new LinkedHashSet<>();
private final Set<RbacGrantDefinition> grantDefs = new LinkedHashSet<>();
private final Set<CaseDef> allCases = new LinkedHashSet<>();
private String discriminatorColumName;
private CaseDef processingCase;
private SQL identityViewSqlQuery;
private SQL orderBySqlExpression;
private EntityAlias rootEntityAliasProxy;
private RbacRoleDefinition previousRoleDef;
private Set<String> limitDiagramToAliasNames;
private final Map<String, CaseDef> cases = new LinkedHashMap<>() {
@Override
public CaseDef put(final String key, final CaseDef value) {
if (containsKey(key)) {
throw new IllegalArgumentException("duplicate case: " + key);
}
return super.put(key, value);
}
};
/** Crates an RBAC definition template for the given entity class and defining the given alias.
*
* @param alias
* an alias name for this entity/table, which can be used in further grants
*
* @param entityClass
* the Java class for which this RBAC definition is to be defined
* (the class to which the calling method belongs)
*
* @return
* the newly created RBAC definition template
*
* @param <E>
* a JPA entity class extending RbacObject
*/
public static <E extends BaseEntity<?>> RbacSpec rbacViewFor(final String alias, final Class<E> entityClass) {
return new RbacSpec(alias, entityClass);
}
RbacSpec(final String alias, final Class<? extends BaseEntity<?>> entityClass) {
rootEntityAlias = new EntityAlias(alias, entityClass);
entityAliases.put(alias, rootEntityAlias);
new RbacSubjectReference(CREATOR);
entityAliases.put("rbac.global", new EntityAlias("rbac.global"));
}
/**
* Specifies, which columns of the restricted view are updatable at all.
*
* @param columnNames
* A list of the updatable columns.
*
* @return
* the `this` instance itself to allow chained calls.
*/
public RbacSpec withUpdatableColumns(final String columnName, final String... columnNames) {
Collections.addAll(updatableColumns, columnName);
Collections.addAll(updatableColumns, columnNames);
verifyVersionColumnExists();
return this;
}
/**
* Specifies, thta no columns are updatable at all.
*
* @return this
*/
public RbacSpec withoutUpdatableColumns() {
updatableColumns.clear();
return this;
}
/** Specifies the SQL query which creates the identity view for this entity.
*
* <p>An identity view is a view which maps an objectUuid to an idName.
* The idName should be a human-readable representation of the row, but as short as possible.
* The idName must only consist of letters (A-Z, a-z), digits (0-9), dash (-), dot (.) and unserscore '_'.
* It's used to create the object-specific-role-names like rbactest.customer#abc:ADMIN - here 'abc' is the idName.
* The idName not necessarily unique in a table, but it should be avoided.
* </p>
*
* @param sqlExpression
* Either specify an SQL projection (the part between SELECT and FROM), e.g. `SQL.projection("columnName")
* or the whole SELECT query returning the uuid and idName columns,
* e.g. `SQL.query("SELECT ... AS uuid, ... AS idName FROM ... JOIN ...").
* Only add really important columns, just enough to create a short human-readable representation.
*
* @return
* the `this` instance itself to allow chained calls.
*/
public RbacSpec withIdentityView(final SQL sqlExpression) {
this.identityViewSqlQuery = sqlExpression;
return this;
}
/**
* Specifies a ORDER BY clause for the generated restricted view.
*
* <p>A restricted view is generated, no matter if the order was specified or not.</p>
*
* @param orderBySqlExpression
* That's the part behind `ORDER BY`, e.g. `SQL.expression("prefix").
*
* @return
* the `this` instance itself to allow chained calls.
*/
public RbacSpec withRestrictedViewOrderBy(final SQL orderBySqlExpression) {
this.orderBySqlExpression = orderBySqlExpression;
return this;
}
/**
* Specifies that the given role (OWNER, ADMIN, ...) is to be created for new/updated roles in this table.
*
* @param role
* OWNER, ADMIN, AGENT etc.
* @param with
* a lambda which receives the created role to create grants and permissions to and from the newly created role,
* e.g. the owning user, incoming superroles, outgoing subroles
* @return
* the `this` instance itself to allow chained calls.
*/
public RbacSpec createRole(final Role role, final Consumer<RbacRoleDefinition> with) {
final RbacRoleDefinition newRoleDef = findRbacRole(rootEntityAlias, role).toCreate();
with.accept(newRoleDef);
previousRoleDef = newRoleDef;
return this;
}
/**
* Specifies that the given role (OWNER, ADMIN, ...) is to be created for new/updated roles in this table,
* which is becomes sub-role of the previously created role.
*
* @param role
* OWNER, ADMIN, AGENT etc.
* @return
* the `this` instance itself to allow chained calls.
*/
public RbacSpec createSubRole(final Role role) {
final RbacRoleDefinition newRoleDef = findRbacRole(rootEntityAlias, role).toCreate();
findOrCreateGrantDef(newRoleDef, previousRoleDef).toCreate();
previousRoleDef = newRoleDef;
return this;
}
/**
* Specifies that the given role (OWNER, ADMIN, ...) is to be created for new/updated roles in this table,
* which is becomes sub-role of the previously created role.
*
* @param role
* OWNER, ADMIN, AGENT etc.
* @param with
* a lambda which receives the created role to create grants and permissions to and from the newly created role,
* e.g. the owning user, incoming superroles, outgoing subroles
* @return
* the `this` instance itself to allow chained calls.
*/
public RbacSpec createSubRole(final Role role, final Consumer<RbacRoleDefinition> with) {
final RbacRoleDefinition newRoleDef = findRbacRole(rootEntityAlias, role).toCreate();
findOrCreateGrantDef(newRoleDef, previousRoleDef).toCreate();
with.accept(newRoleDef);
previousRoleDef = newRoleDef;
return this;
}
/**
* Specifies that the given permission is to be created for each new row in the target table.
*
* <p>Grants to permissions created by this method have to be specified separately,
* often it's easier to read to use createRole/createSubRole and use with.permission(...).</p>
*
* @param permission
* e.g. INSERT, SELECT, UPDATE, DELETE
*
* @return
* the newly created permission definition
*/
public RbacPermissionDefinition createPermission(final Permission permission) {
return createPermission(rootEntityAlias, permission);
}
/**
* Specifies that the given permission is to be created for each new row in the target table,
* but for another table, e.g. a table with details data with different access rights.
*
* <p>Grants to permissions created by this method have to be specified separately,
* often it's easier to read to use createRole/createSubRole and use with.permission(...).</p>
*
* @param entityAliasName
* A previously defined entity alias name.
*
* @param permission
* e.g. INSERT, SELECT, UPDATE, DELETE
*
* @return
* the newly created permission definition
*/
public RbacPermissionDefinition createPermission(final String entityAliasName, final Permission permission) {
return createPermission(findEntityAlias(entityAliasName), permission);
}
private RbacPermissionDefinition createPermission(final EntityAlias entityAlias, final Permission permission) {
return permDefs.stream()
.filter(p -> p.permission == permission && p.entityAlias == entityAlias)
.findFirst()
// .map(g -> g.forCase(processingCase)) TODO.impl: not implemented case dependent
.orElseGet(() -> new RbacPermissionDefinition(entityAlias, permission, null, true));
}
public <EC extends BaseEntity> RbacSpec declarePlaceholderEntityAliases(final String... aliasNames) {
for (String alias : aliasNames) {
entityAliases.put(alias, new EntityAlias(alias));
}
return this;
}
/**
* Imports the RBAC template from the given entity class and defines an alias name for it.
* This method is especially for proxy-entities, if the root entity does not have its own
* roles, a proxy-entity can be specified and its roles can be used instead.
*
* @param aliasName
* An alias name for the entity class. The same entity class can be imported multiple times,
* if multiple references to its table exist, then distinct alias names habe to be defined.
*
* @param entityClass
* A JPA entity class extending RbacObject which also implements an `rbac` method returning
* its RBAC specification.
*
* @param fetchSql
* An SQL SELECT statement which fetches the referenced row. Use `${REF}` to speficiy the
* newly created or updated row (will be replaced by NEW/OLD from the trigger method).
*
* @param dependsOnColum
* The column, usually containing an uuid, on which this other table depends.
*
* @return
* the newly created permission definition
*
* @param <EC>
* a JPA entity class extending RbacObject
*/
public <EC extends BaseEntity<?>> RbacSpec importRootEntityAliasProxy(
final String aliasName,
final Class<? extends BaseEntity<?>> entityClass,
final ColumnValue forCase,
final SQL fetchSql,
final Column dependsOnColum) {
if (rootEntityAliasProxy != null) {
throw new IllegalStateException("there is already an entityAliasProxy: " + rootEntityAliasProxy);
}
rootEntityAliasProxy = importEntityAliasImpl(aliasName, entityClass, forCase, fetchSql, dependsOnColum, false, NOT_NULL);
return this;
}
/**
* Imports the RBAC template from the given entity class and defines an alias name for it.
* This method is especially to declare sub-entities, e.g. details to a main object.
*
* @see {@link}
*
* @return
* the newly created permission definition
*
* @param <EC>
* a JPA entity class extending RbacObject
*/
public RbacSpec importSubEntityAlias(
final String aliasName, final Class<? extends BaseEntity<?>> entityClass,
final SQL fetchSql, final Column dependsOnColum) {
importEntityAliasImpl(aliasName, entityClass, usingDefaultCase(), fetchSql, dependsOnColum, true, NOT_NULL);
return this;
}
/**
* Imports the RBAC template from the given entity class and defines an anlias name for it.
*
* @param aliasName
* An alias name for the entity class. The same entity class can be imported multiple times,
* if multiple references to its table exist, then distinct alias names habe to be defined.
*
* @param entityClass
* A JPA entity class extending RbacObject which also implements an `rbac` method returning
* its RBAC specification.
*
* @param usingCase
* Only use this case value for a switch within the rbac rules.
*
* @param fetchSql
* An SQL SELECT statement which fetches the referenced row. Use `${REF}` to speficiy the
* newly created or updated row (will be replaced by NEW/OLD from the trigger method).
*
* @param dependsOnColum
* The column, usually containing an uuid, on which this other table depends.
*
* @param nullable
* Specifies whether the dependsOnColum is nullable or not.
*
* @return
* the newly created permission definition
*
* @param <EC>
* a JPA entity class extending RbacObject
*/
public RbacSpec importEntityAlias(
final String aliasName, final Class<? extends BaseEntity<?>> entityClass, final ColumnValue usingCase,
final Column dependsOnColum, final SQL fetchSql, final Nullable nullable) {
importEntityAliasImpl(aliasName, entityClass, usingCase, fetchSql, dependsOnColum, false, nullable);
return this;
}
private EntityAlias importEntityAliasImpl(
final String aliasName, final Class<? extends BaseEntity<?>> entityClass, final ColumnValue usingCase,
final SQL fetchSql, final Column dependsOnColum, boolean asSubEntity, final Nullable nullable) {
final var entityAlias = ofNullable(entityAliases.get(aliasName))
.orElseGet(() -> {
final var ea = new EntityAlias(aliasName, entityClass, usingCase, fetchSql, dependsOnColum, asSubEntity, nullable);
entityAliases.put(aliasName, ea);
return ea;
});
try {
// TODO.rbac: this only works for directly recursive RBAC definitions, not for indirect recursion
final var rbacDef = entityClass == rootEntityAlias.entityClass
? this
: rbacDefinition(entityClass);
importAsAlias(aliasName, rbacDef, usingCase, asSubEntity);
} catch (final ReflectiveOperationException exc) {
throw new RuntimeException("cannot import entity: " + entityClass, exc);
}
return entityAlias;
}
private static RbacSpec rbacDefinition(final Class<? extends BaseEntity> entityClass)
throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {
return (RbacSpec) entityClass.getMethod("rbac").invoke(null);
}
private RbacSpec importAsAlias(final String aliasName, final RbacSpec importedRbacView, final ColumnValue forCase, final boolean asSubEntity) {
final var mapper = new AliasNameMapper(importedRbacView, aliasName,
asSubEntity ? entityAliases.keySet() : null);
copyOf(importedRbacView.getEntityAliases().values()).stream()
.filter(entityAlias -> !importedRbacView.isRootEntityAlias(entityAlias))
.filter(entityAlias -> !entityAlias.isGlobal())
.filter(entityAlias -> !asSubEntity || !entityAliases.containsKey(entityAlias.aliasName))
.forEach(entityAlias -> {
final String mappedAliasName = mapper.map(entityAlias.aliasName);
entityAliases.put(mappedAliasName, new EntityAlias(mappedAliasName, entityAlias.entityClass));
});
copyOf(importedRbacView.getRoleDefs()).forEach(roleDef -> {
new RbacRoleDefinition(findEntityAlias(mapper.map(roleDef.entityAlias.aliasName)), roleDef.role);
});
copyOf(importedRbacView.getGrantDefs()).forEach(grantDef -> {
if ( grantDef.grantType() == ROLE_TO_ROLE && grantDef.matchesCase(forCase) ) {
final var importedGrantDef = findOrCreateGrantDef(
findRbacRole(
mapper.map(grantDef.getSubRoleDef().entityAlias.aliasName),
grantDef.getSubRoleDef().getRole()),
findRbacRole(
mapper.map(grantDef.getSuperRoleDef().entityAlias.aliasName),
grantDef.getSuperRoleDef().getRole())
);
if (!grantDef.isAssumed()) {
importedGrantDef.unassumed();
}
}
});
return this;
}
public RbacSpec switchOnColumn(final String discriminatorColumName, final CaseDef... caseDefs) {
this.discriminatorColumName = discriminatorColumName;
allCases.addAll(stream(caseDefs).toList());
stream(caseDefs).forEach(caseDef -> {
this.processingCase = caseDef;
caseDef.def.accept(this);
this.processingCase = null;
});
return this;
}
private static <T> List<T> copyOf(final Collection<T> eas) {
return eas.stream().toList();
}
private void verifyVersionColumnExists() {
final var clazz = rootEntityAlias.entityClass;
if (!hasVersionColumn(clazz)) {
throw new IllegalArgumentException("@Version field required in updatable entity " + rootEntityAlias.entityClass);
}
}
private static boolean hasVersionColumn(final Class<?> clazz) {
if (stream(clazz.getDeclaredFields()).anyMatch(f -> f.getAnnotation(Version.class) != null)) {
return true;
}
if (clazz.getSuperclass() != null) {
return hasVersionColumn(clazz.getSuperclass());
}
return false;
}
/**
* Starts declaring a grant to a given role.
*
* @param entityAlias
* A previously speciried entity alias name.
* @param role
* OWNER, ADMIN, AGENT, ...
* @return
* a grant builder
*/
public RbacGrantBuilder toRole(final String entityAlias, final Role role) {
return new RbacGrantBuilder(entityAlias, role);
}
public RbacExampleRole forExampleRole(final String entityAlias, final Role role) {
return new RbacExampleRole(entityAlias, role);
}
private RbacGrantDefinition grantRoleToSubject(final RbacRoleDefinition roleDefinition, final RbacSubjectReference user) {
return findOrCreateGrantDef(roleDefinition, user).toCreate();
}
private RbacGrantDefinition grantPermissionToRole(
final RbacPermissionDefinition permDef,
final RbacRoleDefinition roleDef) {
return findOrCreateGrantDef(permDef, roleDef).toCreate();
}
private RbacGrantDefinition grantSubRoleToSuperRole(
final RbacRoleDefinition subRoleDefinition,
final RbacRoleDefinition superRoleDefinition) {
return findOrCreateGrantDef(subRoleDefinition, superRoleDefinition).toCreate();
}
boolean isRootEntityAlias(final EntityAlias entityAlias) {
return entityAlias == this.rootEntityAlias;
}
public boolean isEntityAliasProxy(final EntityAlias entityAlias) {
return entityAlias == rootEntityAliasProxy;
}
public SQL getOrderBySqlExpression() {
if (orderBySqlExpression == null) {
return identityViewSqlQuery;
}
return orderBySqlExpression;
}
public void generateWithBaseFileName(final String baseFileName) {
if (allCases.size() > 1) {
allCases.forEach(caseDef -> {
final var fileName = baseFileName + (caseDef.isDefaultCase() ? "" : "-" + caseDef.value) + ".md";
new RbacViewMermaidFlowchartGenerator(this, caseDef)
.generateToMarkdownFile(Path.of(OUTPUT_BASEDIR, fileName));
});
} else {
new RbacViewMermaidFlowchartGenerator(this).generateToMarkdownFile(Path.of(OUTPUT_BASEDIR, baseFileName + ".md"));
}
new RbacViewPostgresGenerator(this).generateToChangeLog(Path.of(OUTPUT_BASEDIR, baseFileName + ".sql"));
}
public RbacSpec limitDiagramTo(final String... aliasNames) {
this.limitDiagramToAliasNames = Set.of(aliasNames);
return this;
}
public boolean renderInDiagram(final EntityAlias ea) {
return limitDiagramToAliasNames == null || limitDiagramToAliasNames.contains(ea.aliasName());
}
public boolean renderInDiagram(final RbacGrantDefinition g) {
if ( limitDiagramToAliasNames == null ) {
return true;
}
return switch (g.grantType()) {
case ROLE_TO_USER ->
renderInDiagram(g.getSubRoleDef().getEntityAlias());
case ROLE_TO_ROLE ->
renderInDiagram(g.getSuperRoleDef().getEntityAlias()) && renderInDiagram(g.getSubRoleDef().getEntityAlias());
case PERM_TO_ROLE ->
renderInDiagram(g.getSuperRoleDef().getEntityAlias()) && renderInDiagram(g.getPermDef().getEntityAlias());
};
}
public class RbacGrantBuilder {
private final RbacRoleDefinition superRoleDef;
private RbacGrantBuilder(final String entityAlias, final Role role) {
this.superRoleDef = findRbacRole(entityAlias, role);
}
public RbacSpec grantRole(final String entityAlias, final Role role) {
findOrCreateGrantDef(findRbacRole(entityAlias, role), superRoleDef).toCreate();
return RbacSpec.this;
}
public RbacSpec grantPermission(final Permission perm) {
final var forTable = rootEntityAlias.getRawTableNameWithSchema();
findOrCreateGrantDef(findRbacPerm(rootEntityAlias, perm, forTable), superRoleDef).toCreate();
return RbacSpec.this;
}
}
public enum Nullable {
NOT_NULL, // DEFAULT
NULLABLE
}
@Getter
@EqualsAndHashCode
public class RbacGrantDefinition {
private final RbacSubjectReference userDef;
private final RbacRoleDefinition superRoleDef;
private final RbacRoleDefinition subRoleDef;
private final RbacPermissionDefinition permDef;
private boolean assumed = true;
private boolean toCreate = false;
private Set<CaseDef> forCases = new LinkedHashSet<>();
@Override
public String toString() {
final var arrow = isAssumed() ? " --> " : " -- // --> ";
final var grant = switch (grantType()) {
case ROLE_TO_USER -> userDef.toString() + arrow + subRoleDef.toString();
case ROLE_TO_ROLE -> superRoleDef + arrow + subRoleDef;
case PERM_TO_ROLE -> superRoleDef + arrow + permDef;
};
final var condition = isConditional()
? (" (" +forCases.stream().map(CaseDef::toString).collect(Collectors.joining("||")) + ")")
: "";
return grant + condition;
}
RbacGrantDefinition(final RbacRoleDefinition subRoleDef, final RbacRoleDefinition superRoleDef, final CaseDef forCase) {
this.userDef = null;
this.subRoleDef = subRoleDef;
this.superRoleDef = superRoleDef;
this.permDef = null;
this.forCases = forCase != null ? hashSet(forCase) : null;
register(this);
}
public RbacGrantDefinition(final RbacPermissionDefinition permDef, final RbacRoleDefinition roleDef,
final CaseDef forCase) {
this.userDef = null;
this.subRoleDef = null;
this.superRoleDef = roleDef;
this.permDef = permDef;
this.forCases = forCase != null ? hashSet(forCase) : null;
register(this);
}
public RbacGrantDefinition(final RbacRoleDefinition roleDef, final RbacSubjectReference userDef) {
this.userDef = userDef;
this.subRoleDef = roleDef;
this.superRoleDef = null;
this.permDef = null;
register(this);
}
private void register(final RbacGrantDefinition rbacGrantDefinition) {
grantDefs.add(rbacGrantDefinition);
}
@NotNull
GrantType grantType() {
return permDef != null ? PERM_TO_ROLE
: userDef != null ? GrantType.ROLE_TO_USER
: ROLE_TO_ROLE;
}
boolean isAssumed() {
return assumed;
}
RbacGrantDefinition forCase(final CaseDef processingCase) {
forCases.add(processingCase);
return this;
}
boolean isConditional() {
return forCases != null && !forCases.isEmpty() && forCases.size()<allCases.size();
}
boolean matchesCase(final ColumnValue requestedCase) {
final var noCasesDefined = forCases == null;
final var generateForAllCases = requestedCase == null;
final boolean isGrantedForRequestedCase = forCases == null || forCases.stream().anyMatch(c -> c.isCase(requestedCase))
|| forCases.stream().anyMatch(CaseDef::isDefaultCase) && !allCases.stream().anyMatch(c -> c.isCase(requestedCase));
return noCasesDefined || generateForAllCases || isGrantedForRequestedCase;
}
boolean isToCreate() {
return toCreate;
}
RbacGrantDefinition toCreate() {
toCreate = true;
return this;
}
boolean dependsOnColumn(final String columnName) {
return dependsRoleDefOnColumnName(this.superRoleDef, columnName)
|| dependsRoleDefOnColumnName(this.subRoleDef, columnName);
}
private Boolean dependsRoleDefOnColumnName(final RbacRoleDefinition superRoleDef, final String columnName) {
return ofNullable(superRoleDef)
.map(r -> r.getEntityAlias().dependsOnColum())
.map(d -> columnName.equals(d.column))
.orElse(false);
}
public RbacGrantDefinition unassumed() {
this.assumed = false;
return this;
}
public long level() {
return max(asList(
superRoleDef != null ? superRoleDef.entityAlias.level() : 0,
subRoleDef != null ? subRoleDef.entityAlias.level() : 0,
permDef != null ? permDef.entityAlias.level() : 0));
}
public enum GrantType {
ROLE_TO_USER,
ROLE_TO_ROLE,
PERM_TO_ROLE
}
}
public class RbacExampleRole {
final EntityAlias subRoleEntity;
final Role subRole;
private EntityAlias superRoleEntity;
Role superRole;
public RbacExampleRole(final String entityAlias, final Role role) {
this.subRoleEntity = findEntityAlias(entityAlias);
this.subRole = role;
}
public RbacSpec wouldBeGrantedTo(final String entityAlias, final Role role) {
this.superRoleEntity = findEntityAlias(entityAlias);
this.superRole = role;
return RbacSpec.this;
}
}
@Getter
@EqualsAndHashCode
public class RbacPermissionDefinition {
final EntityAlias entityAlias;
final Permission permission;
final String tableName;
final boolean toCreate;
private RbacPermissionDefinition(final EntityAlias entityAlias, final Permission permission, final String tableName,
final boolean toCreate) {
this.entityAlias = entityAlias;
this.permission = permission;
this.tableName = tableName;
this.toCreate = toCreate;
permDefs.add(this);
}
/**
* Grants the permission under definition to the given role.
*
* @param entityAlias
* A previously declared entity alias name.
* @param role
* OWNER, ADMIN, ...
* @return
* The RbacView specification to which this permission definition belongs.
*/
public RbacSpec grantedTo(final String entityAlias, final Role role) {
findOrCreateGrantDef(this, findRbacRole(entityAlias, role)).toCreate();
return RbacSpec.this;
}
@Override
public String toString() {
return "perm:" + entityAlias.aliasName + permission + ofNullable(tableName).map(tn -> ":" + tn).orElse("");
}
}
@Getter
@EqualsAndHashCode
public class RbacRoleDefinition {
private final EntityAlias entityAlias;
private final Role role;
private boolean toCreate;
public RbacRoleDefinition(final EntityAlias entityAlias, final Role role) {
this.entityAlias = entityAlias;
this.role = role;
roleDefs.add(this);
}
public RbacRoleDefinition toCreate() {
this.toCreate = true;
return this;
}
/**
* Specifies which user becomes the owner of newly created objects.
* @param userRole
* GLOBAL_ADMIN, CREATOR, ...
* @return
* The grant definition for further chained calls.
*/
public RbacGrantDefinition owningUser(final RbacSubjectReference.UserRole userRole) {
return grantRoleToSubject(this, findUserRef(userRole));
}
/**
* Specifies which permission is to be created for newly created objects.
* @param permission
* INSERT, SELECT, ...
* @return
* The grant definition for further chained calls.
*/
public RbacGrantDefinition permission(final Permission permission) {
return grantPermissionToRole(createPermission(entityAlias, permission), this);
}
/**
* Specifies in incoming super role which gets granted the role under definition.
*
* <p>Incoming means an incoming grant arrow in our grant-diagrams.
* Super-role means that it's the role to which another role is granted.
* Both means actually the same, just in different aspects.</p>
*
* @param entityAlias
* A previously declared entity alias name.
* @param role
* OWNER, ADMIN, ...
* @return
* The grant definition for further chained calls.
*/
public RbacGrantDefinition incomingSuperRole(final String entityAlias, final Role role) {
final var incomingSuperRole = findRbacRole(entityAlias, role);
return grantSubRoleToSuperRole(this, incomingSuperRole);
}
/**
* Specifies in outgoing sub role which gets granted the role under definition.
*
* <p>Outgoing means an outgoing grant arrow in our grant-diagrams.
* Sub-role means which is granted to another role.
* Both means actually the same, just in different aspects.</p>
*
* @param entityAlias
* A previously declared entity alias name.
* @param role
* OWNER, ADMIN, ...
* @return
* The grant definition for further chained calls.
*/
public RbacGrantDefinition outgoingSubRole(final String entityAlias, final Role role) {
final var outgoingSubRole = findRbacRole(entityAlias, role);
return grantSubRoleToSuperRole(outgoingSubRole, this);
}
@Override
public String toString() {
return "role:" + entityAlias.aliasName + role;
}
public boolean isGlobal(final Role role) {
return entityAlias.isGlobal() && this.role == role;
}
public String descriptorFunctionName() {
return entityAlias.getRawTableNameWithSchema() + "_" + capitalize(role.name());
}
}
public RbacSubjectReference findUserRef(final RbacSubjectReference.UserRole userRole) {
return userDefs.stream().filter(u -> u.role == userRole).findFirst().orElseThrow();
}
@EqualsAndHashCode
public class RbacSubjectReference {
public enum UserRole {
GLOBAL_ADMIN,
CREATOR
}
final UserRole role;
public RbacSubjectReference(final UserRole creator) {
this.role = creator;
userDefs.add(this);
}
@Override
public String toString() {
return "user:" + role;
}
}
EntityAlias findEntityAlias(final String aliasName) {
final var found = entityAliases.get(aliasName);
if (found == null) {
throw new IllegalArgumentException("entityAlias not found: " + aliasName);
}
return found;
}
RbacRoleDefinition findRbacRole(final EntityAlias entityAlias, final Role role) {
return roleDefs.stream()
.filter(r -> r.getEntityAlias() == entityAlias && r.getRole().equals(role))
.findFirst()
.orElseGet(() -> new RbacRoleDefinition(entityAlias, role));
}
public RbacRoleDefinition findRbacRole(final String entityAliasName, final Role role) {
return findRbacRole(findEntityAlias(entityAliasName), role);
}
RbacPermissionDefinition findRbacPerm(final EntityAlias entityAlias, final Permission perm, String tableName) {
return permDefs.stream()
.filter(p -> p.getEntityAlias() == entityAlias && p.getPermission() == perm)
.findFirst()
.orElseGet(() -> new RbacPermissionDefinition(entityAlias, perm, tableName, true)); // TODO: true => toCreate
}
private RbacGrantDefinition findOrCreateGrantDef(final RbacRoleDefinition roleDefinition, final RbacSubjectReference user) {
return grantDefs.stream()
.filter(g -> g.subRoleDef == roleDefinition && g.userDef == user)
.findFirst()
.orElseGet(() -> new RbacGrantDefinition(roleDefinition, user));
}
private RbacGrantDefinition findOrCreateGrantDef(final RbacPermissionDefinition permDef, final RbacRoleDefinition roleDef) {
return grantDefs.stream()
.filter(g -> g.permDef == permDef && g.superRoleDef == roleDef)
.findFirst()
.map(g -> g.forCase(processingCase))
.orElseGet(() -> new RbacGrantDefinition(permDef, roleDef, processingCase));
}
private RbacGrantDefinition findOrCreateGrantDef(
final RbacRoleDefinition subRoleDefinition,
final RbacRoleDefinition superRoleDefinition) {
final var distinctGrantDef = grantDefs.stream()
.filter(g -> g.subRoleDef == subRoleDefinition && g.superRoleDef == superRoleDefinition)
.findFirst()
.map(g -> g.forCase(processingCase))
.orElseGet(() -> new RbacGrantDefinition(subRoleDefinition, superRoleDefinition, processingCase));
return distinctGrantDef;
}
record EntityAlias(String aliasName, Class<? extends BaseEntity<?>> entityClass, ColumnValue usingCase, SQL fetchSql, Column dependsOnColum, boolean isSubEntity, Nullable nullable) {
public EntityAlias(final String aliasName) {
this(aliasName, null, null, null, null, false, null);
}
public EntityAlias(final String aliasName, final Class<? extends BaseEntity<?>> entityClass) {
this(aliasName, entityClass, null, null, null, false, null);
}
boolean isGlobal() {
return aliasName().equals("rbac.global");
}
boolean isPlaceholder() {
return entityClass == null;
}
@NotNull
public SQL fetchSql() {
if (fetchSql == null) {
return SQL.noop();
}
return switch (fetchSql.part) {
case SQL_QUERY -> fetchSql;
case AUTO_FETCH ->
SQL.query("SELECT * FROM " + getRawTableNameWithSchema() + " WHERE uuid = ${ref}." + dependsOnColum.column);
default -> throw new IllegalStateException("unexpected SQL definition: " + fetchSql);
};
}
boolean isFetchedByDirectForeignKey() {
return fetchSql != null && fetchSql.part == AUTO_FETCH;
}
private String withoutEntitySuffix(final String simpleEntityName) {
// TODO.impl: maybe introduce an annotation like @RbacObjectName("hsOfficeContact")?
if ( simpleEntityName.endsWith("RbacEntity")) {
return simpleEntityName.substring(0, simpleEntityName.length() - "RbacEntity".length());
}
return simpleEntityName.substring(0, simpleEntityName.length() - "Entity".length());
}
String simpleName() {
return isGlobal()
? aliasName
: uncapitalize(withoutEntitySuffix(entityClass.getSimpleName()));
}
String getRawTableNameWithSchema() {
if ( aliasName.equals("rbac.global")) {
return "rbac.global"; // TODO: maybe we should introduce a GlobalEntity class?
}
return qualifiedRealTableName(entityClass);
}
String getRawTableSchemaPrefix() {
final var rawTableNameWithSchema = getRawTableNameWithSchema();
final var parts = rawTableNameWithSchema.split("\\.");
final var rawTableSchemaPrefix = parts.length > 1 ? parts[0] + "." : "";
return rawTableSchemaPrefix;
}
String getRawTableName() {
final var rawTableNameWithSchema = getRawTableNameWithSchema();
final var parts = rawTableNameWithSchema.split("\\.");
final var rawTableName = parts.length > 1 ? parts[1] : rawTableNameWithSchema;
return rawTableName;
}
String getRawTableShortName() {
// TODO.impl: some combined function and trigger names are too long
// maybe we should shorten the table name e.g. hs_office.coopsharetx -> hsof.coopsharetx
// this is just a workaround:
return getRawTableName()
.replace("hs_office.", "hsof.")
.replace("hs_booking.", "hsbk_")
.replace("hs_hosting.", "hsho_");
}
String dependsOnColumName() {
if (dependsOnColum == null) {
throw new IllegalStateException(
"Entity " + aliasName + "(" + entityClass.getSimpleName() + ")" + ": please add dependsOnColum");
}
return dependsOnColum.column;
}
long level() {
return aliasName.chars().filter(ch -> ch == '.').count() + 1;
}
boolean isCaseDependent() {
return usingCase != null && usingCase.value != null;
}
}
public static String qualifiedRealTableName(final Class<? extends BaseEntity<?>> entityClass) {
final var tableAnnotation = entityClass.getAnnotation(Table.class);
final var schema = tableAnnotation.schema();
final var tableName = tableAnnotation.name();
final var realTableName = tableName.substring(0, tableName.length() - "_rv".length());
return (schema.isEmpty() ? "" : (schema + ".")) + realTableName;
}
public enum Role {
OWNER,
ADMIN,
AGENT,
TENANT,
REFERRER,
@Deprecated
GUEST;
@Override
public String toString() {
return ":" + name();
}
}
public enum Permission {
INSERT,
DELETE,
UPDATE,
SELECT;
@Override
public String toString() {
return ":" + name();
}
}
public static class SQL {
/**
* DSL method to specify an SQL SELECT expression which fetches the related entity,
* using the reference `${ref}` of the root entity and `${columns}` for the projection.
*
* <p>The query <strong>must define</strong> the entity alias name of the fetched table
* as its alias for, so it can be used in the generated projection (the columns between
* `SELECT` and `FROM`.</p>
*
* <p>`${ref}` is going to be replaced by either `NEW` or `OLD` of the trigger function.
* `into ...` will be added with a variable name prefixed with either `new` or `old`.</p>
*
* <p>`${columns}` is going to be replaced by the columns which are needed for the query,
* e.g. `*` or `uuid`.</p>
*
* @param sql an SQL SELECT expression (not ending with ';)
* @return the wrapped SQL expression
*/
public static SQL fetchedBySql(final String sql) {
if ( !sql.startsWith("SELECT ${columns}") ) {
throw new IllegalArgumentException("SQL SELECT expression must start with 'SELECT ${columns}' but is: " + sql);
}
validateExpression(sql);
return new SQL(sql, Part.SQL_QUERY);
}
/**
* DSL method to specify that a related entity is to be fetched by a simple SELECT statement
* using the raw table from the @Table statement of the entity to fetch
* and the dependent column of the root entity.
*
* @return the wrapped SQL definition object
*/
public static SQL directlyFetchedByDependsOnColumn() {
return new SQL(null, AUTO_FETCH);
}
/**
* DSL method to explicitly specify that there is no SQL query.
*
* @return a wrapped SQL definition object representing a noop query
*/
public static SQL noop() {
return new SQL(null, Part.NOOP);
}
/**
* Generic DSL method to specify an SQL SELECT expression.
*
* @param sql an SQL SELECT expression (not ending with ';)
* @return the wrapped SQL expression
*/
public static SQL query(final String sql) {
validateExpression(sql);
return new SQL(sql, Part.SQL_QUERY);
}
/**
* Generic DSL method to specify an SQL SELECT expression by just the projection part.
*
* @param projection an SQL SELECT expression, the list of columns after 'SELECT'
* @return the wrapped SQL projection
*/
public static SQL projection(final String projection) {
validateProjection(projection);
return new SQL(projection, Part.SQL_PROJECTION);
}
public static SQL expression(final String sqlExpression) {
// TODO: validate
return new SQL(sqlExpression, Part.SQL_EXPRESSION);
}
enum Part {
NOOP,
SQL_QUERY,
AUTO_FETCH,
SQL_PROJECTION,
SQL_EXPRESSION
}
final String sql;
final Part part;
private SQL(final String sql, final Part part) {
this.sql = sql;
this.part = part;
}
private static void validateProjection(final String projection) {
if (projection.toUpperCase().matches("[ \t]*$SELECT[ \t]")) {
throw new IllegalArgumentException("SQL projection must not start with 'SELECT': " + projection);
}
if (projection.matches(";[ \t]*$")) {
throw new IllegalArgumentException("SQL projection must not end with ';': " + projection);
}
}
private static void validateExpression(final String sql) {
if (sql.matches(";[ \t]*$")) {
throw new IllegalArgumentException("SQL expression must not end with ';': " + sql);
}
}
}
public static class Column {
public static Column dependsOnColumn(final String column) {
return new Column(column);
}
public final String column;
private Column(final String column) {
this.column = column;
}
}
public static class ColumnValue {
public static ColumnValue usingDefaultCase() {
return new ColumnValue(null);
}
public static <E extends Enum<E>> ColumnValue usingCase(final E value) {
return new ColumnValue(value.name());
}
public final String value;
private ColumnValue(final String value) {
this.value = value;
}
}
private static class AliasNameMapper {
private final RbacSpec importedRbacView;
private final String outerAliasName;
private final Set<String> outerAliasNames;
AliasNameMapper(final RbacSpec importedRbacView, final String outerAliasName, final Set<String> outerAliasNames) {
this.importedRbacView = importedRbacView;
this.outerAliasName = outerAliasName;
this.outerAliasNames = (outerAliasNames == null) ? Collections.emptySet() : outerAliasNames;
}
String map(final String originalAliasName) {
if (outerAliasNames.contains(originalAliasName) || originalAliasName.equals("rbac.global")) {
return originalAliasName;
}
if (originalAliasName.equals(importedRbacView.rootEntityAlias.aliasName)) {
return outerAliasName;
}
return outerAliasName + "." + originalAliasName;
}
}
public static class CaseDef extends ColumnValue {
final Consumer<RbacSpec> def;
private CaseDef(final String discriminatorColumnValue, final Consumer<RbacSpec> def) {
super(discriminatorColumnValue);
this.def = def;
}
public static CaseDef inCaseOf(final String discriminatorColumnValue, final Consumer<RbacSpec> def) {
return new CaseDef(discriminatorColumnValue, def);
}
public static CaseDef inOtherCases(final Consumer<RbacSpec> def) {
return new CaseDef(null, def);
}
@Override
public int hashCode() {
return ofNullable(value).map(String::hashCode).orElse(0);
}
@Override
public boolean equals(final Object other) {
if (this == other)
return true;
if (other == null || getClass() != other.getClass())
return false;
final CaseDef caseDef = (CaseDef) other;
return Objects.equals(value, caseDef.value);
}
boolean isDefaultCase() {
return value == null;
}
@Override
public String toString() {
return isDefaultCase()
? "inOtherCases"
: "inCaseOf:" + value;
}
public boolean isCase(final ColumnValue requestedCase) {
return Objects.equals(requestedCase.value, this.value);
}
}
private static void generateRbacView(final Class<? extends BaseEntity> c) {
final Method mainMethod = stream(c.getMethods()).filter(
m -> isStatic(m.getModifiers()) && m.getName().equals("main")
)
.findFirst()
.orElseThrow(() -> new RuntimeException("no main method in: " + c.getName() + " => cannot generate RBAC rules"));
try {
mainMethod.invoke(null, new Object[] { null });
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
}
public static Set<Class<? extends BaseEntity>> findRbacEntityClasses(String packageName) {
final var reflections = new Reflections(packageName, TypeAnnotationsScanner.class);
final Set<Class<? extends BaseEntity>> rbacEntityClasses = reflections.getTypesAnnotatedWith(Entity.class).stream()
.filter(BaseEntity.class::isAssignableFrom)
.filter(c -> stream(c.getDeclaredMethods())
.anyMatch(m -> m.getName().equals("rbac") && isStatic(m.getModifiers()))
)
.map(RbacSpec::castToSubclassOfBaseEntity)
.collect(Collectors.toSet());
return rbacEntityClasses;
}
@SuppressWarnings("unchecked")
private static Class<? extends BaseEntity> castToSubclassOfBaseEntity(final Class<?> clazz) {
return (Class<? extends BaseEntity>) clazz;
}
/**
* This main method generates the RbacViews (PostgreSQL+diagram) for all given entity classes.
*/
public static void main(String[] args) throws Exception {
findRbacEntityClasses("net.hostsharing.hsadminng")
.forEach(RbacSpec::generateRbacView);
}
}