1
0

login-credentials without RBAC (#173)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/173
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
Michael Hoennig
2025-05-19 12:00:35 +02:00
parent 965866dadc
commit 58096c1510
55 changed files with 2232 additions and 79 deletions
@@ -0,0 +1,62 @@
package net.hostsharing.hsadminng.credentials;
import jakarta.persistence.Column;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
import jakarta.validation.constraints.NotNull;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.SuperBuilder;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.repr.Stringify;
import net.hostsharing.hsadminng.repr.Stringifyable;
import java.util.UUID;
import static net.hostsharing.hsadminng.repr.Stringify.stringify;
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@SuperBuilder(builderMethodName = "baseBuilder", toBuilder = true)
@MappedSuperclass
public abstract class HsCredentialsContext implements Stringifyable, BaseEntity<HsCredentialsContext> {
private static Stringify<HsCredentialsContext> stringify = stringify(HsCredentialsContext.class, "loginContext")
.withProp(HsCredentialsContext::getType)
.withProp(HsCredentialsContext::getQualifier)
.quotedValues(false)
.withSeparator(":");
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "uuid", nullable = false, updatable = false)
private UUID uuid;
@NotNull
@Column
private int version;
@NotNull
@Column(name = "type", length = 16)
private String type;
@Column(name = "qualifier", length = 80)
private String qualifier;
@Override
public String toShortString() {
return toString();
}
@Override
public String toString() {
return stringify.apply(this);
}
}
@@ -0,0 +1,55 @@
package net.hostsharing.hsadminng.credentials;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.SuperBuilder;
import net.hostsharing.hsadminng.rbac.generator.RbacSpec;
import net.hostsharing.hsadminng.rbac.generator.RbacSpec.SQL;
import java.io.IOException;
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.GLOBAL;
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Permission.*;
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.GUEST;
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.OWNER;
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.ADMIN;
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.REFERRER;
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.WITHOUT_IMPLICIT_GRANTS;
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.rbacViewFor;
@Entity
@Table(schema = "hs_credentials", name = "context") // TODO_impl: RBAC rules for _rv do not yet work properly
@SuperBuilder(toBuilder = true)
@Getter
@Setter
@NoArgsConstructor
@AttributeOverrides({
@AttributeOverride(name = "uuid", column = @Column(name = "uuid"))
})
public class HsCredentialsContextRbacEntity extends HsCredentialsContext {
// TODO_impl: RBAC rules for _rv do not yet work properly (remove the X)
public static RbacSpec rbacX() {
return rbacViewFor("credentialsContext", HsCredentialsContextRbacEntity.class)
.withIdentityView(SQL.projection("type || ':' || qualifier"))
.withRestrictedViewOrderBy(SQL.expression("type || ':' || qualifier"))
.withoutUpdatableColumns()
.createRole(OWNER, WITHOUT_IMPLICIT_GRANTS)
.createSubRole(ADMIN, WITHOUT_IMPLICIT_GRANTS)
.createSubRole(REFERRER, WITHOUT_IMPLICIT_GRANTS)
.toRole(GLOBAL, ADMIN).grantPermission(INSERT)
.toRole(GLOBAL, ADMIN).grantPermission(DELETE)
.toRole(GLOBAL, GUEST).grantPermission(SELECT);
}
// TODO_impl: RBAC rules for _rv do not yet work properly (remove the X)
public static void mainX(String[] args) throws IOException {
rbacX().generateWithBaseFileName("9-hs-global/950-credentials/9513-hs-credentials-rbac");
}
}
@@ -0,0 +1,24 @@
package net.hostsharing.hsadminng.credentials;
import io.micrometer.core.annotation.Timed;
import org.springframework.data.repository.Repository;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface HsCredentialsContextRbacRepository extends Repository<HsCredentialsContextRbacEntity, UUID> {
@Timed("app.login.context.repo.findAll")
List<HsCredentialsContextRbacEntity> findAll();
@Timed("app.login.context.repo.findByUuid")
Optional<HsCredentialsContextRbacEntity> findByUuid(final UUID id);
@Timed("app.login.context.repo.findByTypeAndQualifier")
Optional<HsCredentialsContextRbacEntity> findByTypeAndQualifier(@NotNull String contextType, @NotNull String qualifier);
@Timed("app.login.context.repo.save")
HsCredentialsContextRbacEntity save(final HsCredentialsContextRbacEntity entity);
}
@@ -0,0 +1,23 @@
package net.hostsharing.hsadminng.credentials;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.SuperBuilder;
@Entity
@Table(schema = "hs_credentials", name = "context")
@SuperBuilder(toBuilder = true)
@Getter
@Setter
@NoArgsConstructor
@AttributeOverrides({
@AttributeOverride(name = "uuid", column = @Column(name = "uuid"))
})
public class HsCredentialsContextRealEntity extends HsCredentialsContext {
}
@@ -0,0 +1,24 @@
package net.hostsharing.hsadminng.credentials;
import io.micrometer.core.annotation.Timed;
import org.springframework.data.repository.Repository;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface HsCredentialsContextRealRepository extends Repository<HsCredentialsContextRealEntity, UUID> {
@Timed("app.login.context.repo.findAll")
List<HsCredentialsContextRealEntity> findAll();
@Timed("app.login.context.repo.findByUuid")
Optional<HsCredentialsContextRealEntity> findByUuid(final UUID id);
@Timed("app.login.context.repo.findByTypeAndQualifier")
Optional<HsCredentialsContextRealEntity> findByTypeAndQualifier(@NotNull String contextType, @NotNull String qualifier);
@Timed("app.login.context.repo.save")
HsCredentialsContextRealEntity save(final HsCredentialsContextRealEntity entity);
}
@@ -0,0 +1,33 @@
package net.hostsharing.hsadminng.credentials;
import java.util.List;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.credentials.generated.api.v1.api.LoginContextsApi;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginContextResource;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HsCredentialsContextsController implements LoginContextsApi {
@Autowired
private Context context;
@Autowired
private StrictMapper mapper;
@Autowired
private HsCredentialsContextRbacRepository contextRepo;
@Override
public ResponseEntity<List<LoginContextResource>> getListOfLoginContexts(final String assumedRoles) {
context.assumeRoles(assumedRoles);
final var loginContexts = contextRepo.findAll();
final var result = mapper.mapList(loginContexts, LoginContextResource.class);
return ResponseEntity.ok(result);
}
}
@@ -0,0 +1,96 @@
package net.hostsharing.hsadminng.credentials;
import java.util.List;
import java.util.UUID;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.credentials.generated.api.v1.api.LoginCredentialsApi;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsInsertResource;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsPatchResource;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsResource;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HsCredentialsController implements LoginCredentialsApi {
@Autowired
private Context context;
@Autowired
private EntityManagerWrapper em;
@Autowired
private StrictMapper mapper;
@Autowired
private HsOfficePersonRbacRepository personRepo;
@Autowired
private HsCredentialsRepository loginCredentialsRepo;
@Override
public ResponseEntity<LoginCredentialsResource> getSingleLoginCredentialsByUuid(
final String assumedRoles,
final UUID loginCredentialsUuid) {
context.assumeRoles(assumedRoles);
final var credentials = loginCredentialsRepo.findByUuid(loginCredentialsUuid);
final var result = mapper.map(credentials, LoginCredentialsResource.class);
return ResponseEntity.ok(result);
}
@Override
public ResponseEntity<List<LoginCredentialsResource>> getListOfLoginCredentialsByPersonUuid(
final String assumedRoles,
final UUID personUuid
) {
context.assumeRoles(assumedRoles);
final var person = personRepo.findByUuid(personUuid).orElseThrow(); // FIXME: use proper exception
final var credentials = loginCredentialsRepo.findByPerson(person);
final var result = mapper.mapList(credentials, LoginCredentialsResource.class);
return ResponseEntity.ok(result);
}
@Override
public ResponseEntity<LoginCredentialsResource> postNewLoginCredentials(
final String assumedRoles,
final LoginCredentialsInsertResource body
) {
context.assumeRoles(assumedRoles);
final var newLoginCredentialsEntity = mapper.map(body, HsCredentialsEntity.class);
final var savedLoginCredentialsEntity = loginCredentialsRepo.save(newLoginCredentialsEntity);
final var newLoginCredentialsResource = mapper.map(savedLoginCredentialsEntity, LoginCredentialsResource.class);
return ResponseEntity.ok(newLoginCredentialsResource);
}
@Override
public ResponseEntity<Void> deleteLoginCredentialsByUuid(final String assumedRoles, final UUID loginCredentialsUuid) {
context.assumeRoles(assumedRoles);
final var loginCredentialsEntity = em.getReference(HsCredentialsEntity.class, loginCredentialsUuid);
em.remove(loginCredentialsEntity);
return ResponseEntity.noContent().build();
}
@Override
public ResponseEntity<LoginCredentialsResource> patchLoginCredentials(
final String assumedRoles,
final UUID loginCredentialsUuid,
final LoginCredentialsPatchResource body
) {
context.assumeRoles(assumedRoles);
final var current = loginCredentialsRepo.findByUuid(loginCredentialsUuid).orElseThrow();
new HsCredentialsEntityPatcher(em, current).apply(body);
final var saved = loginCredentialsRepo.save(current);
final var mapped = mapper.map(saved, LoginCredentialsResource.class);
return ResponseEntity.ok(mapped);
}
}
@@ -0,0 +1,100 @@
package net.hostsharing.hsadminng.credentials;
import jakarta.persistence.*;
import lombok.*;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity; // Assuming BaseEntity exists
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
import net.hostsharing.hsadminng.repr.Stringify;
import net.hostsharing.hsadminng.repr.Stringifyable;
// import net.hostsharing.hsadminng.rbac.RbacSubjectEntity; // Assuming RbacSubjectEntity exists for the FK relationship
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import static jakarta.persistence.CascadeType.MERGE;
import static jakarta.persistence.CascadeType.REFRESH;
import static net.hostsharing.hsadminng.repr.Stringify.stringify;
@Entity
@Table(schema = "hs_credentials", name = "credentials")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HsCredentialsEntity implements BaseEntity<HsCredentialsEntity>, Stringifyable {
protected static Stringify<HsCredentialsEntity> stringify = stringify(HsCredentialsEntity.class, "loginCredentials")
.withProp(HsCredentialsEntity::isActive)
.withProp(HsCredentialsEntity::getEmailAddress)
.withProp(HsCredentialsEntity::getTwoFactorAuth)
.withProp(HsCredentialsEntity::getPhonePassword)
.withProp(HsCredentialsEntity::getSmsNumber)
.quotedValues(false);
@Id
private UUID uuid;
@MapsId
@OneToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
private RbacSubjectEntity subject;
@ManyToOne(optional = false, fetch = FetchType.EAGER)
@JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
private HsOfficePersonRealEntity person;
@Version
private int version;
@Column
private boolean active;
@Column
private Integer globalUid;
@Column
private Integer globalGid;
@Column
private String onboardingToken;
@Column
private String twoFactorAuth;
@Column
private String phonePassword;
@Column
private String emailAddress;
@Column
private String smsNumber;
@OneToMany(fetch = FetchType.LAZY, cascade = { MERGE, REFRESH }, orphanRemoval = true)
@JoinTable(
name = "context_mapping", schema = "hs_credentials",
joinColumns = @JoinColumn(name = "credentials_uuid", referencedColumnName = "uuid"),
inverseJoinColumns = @JoinColumn(name = "context_uuid", referencedColumnName = "uuid")
)
private Set<HsCredentialsContextRealEntity> loginContexts;
public Set<HsCredentialsContextRealEntity> getLoginContexts() {
if ( loginContexts == null ) {
loginContexts = new HashSet<>();
}
return loginContexts;
}
@Override
public String toShortString() {
return active + ":" + emailAddress + ":" + globalUid;
}
@Override
public String toString() {
return stringify.apply(this);
}
}
@@ -0,0 +1,74 @@
package net.hostsharing.hsadminng.credentials;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginContextResource;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsPatchResource;
import net.hostsharing.hsadminng.mapper.EntityPatcher;
import net.hostsharing.hsadminng.mapper.OptionalFromJson;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityNotFoundException;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public class HsCredentialsEntityPatcher implements EntityPatcher<LoginCredentialsPatchResource> {
private final EntityManager em;
private final HsCredentialsEntity entity;
public HsCredentialsEntityPatcher(final EntityManager em, final HsCredentialsEntity entity) {
this.em = em;
this.entity = entity;
}
@Override
public void apply(final LoginCredentialsPatchResource resource) {
if ( resource.getActive() != null ) {
entity.setActive(resource.getActive());
}
OptionalFromJson.of(resource.getEmailAddress())
.ifPresent(entity::setEmailAddress);
OptionalFromJson.of(resource.getTwoFactorAuth())
.ifPresent(entity::setTwoFactorAuth);
OptionalFromJson.of(resource.getSmsNumber())
.ifPresent(entity::setSmsNumber);
OptionalFromJson.of(resource.getPhonePassword())
.ifPresent(entity::setPhonePassword);
if (resource.getContexts() != null) {
syncLoginContextEntities(resource.getContexts(), entity.getLoginContexts());
}
}
public void syncLoginContextEntities(
List<LoginContextResource> resources,
Set<HsCredentialsContextRealEntity> entities
) {
final var resourceUuids = resources.stream()
.map(LoginContextResource::getUuid)
.collect(Collectors.toSet());
final var entityUuids = entities.stream()
.map(HsCredentialsContextRealEntity::getUuid)
.collect(Collectors.toSet());
entities.removeIf(e -> !resourceUuids.contains(e.getUuid()));
for (final var resource : resources) {
if (!entityUuids.contains(resource.getUuid())) {
final var existingContextEntity = em.find(HsCredentialsContextRealEntity.class, resource.getUuid());
if ( existingContextEntity == null ) {
// FIXME: i18n
throw new EntityNotFoundException(
HsCredentialsContextRealEntity.class.getName() + " with uuid " + resource.getUuid() + " not found.");
}
if (!existingContextEntity.getType().equals(resource.getType().name()) &&
!existingContextEntity.getQualifier().equals(resource.getQualifier())) {
// FIXME: i18n
throw new EntityNotFoundException("existing " + existingContextEntity + " does not match given resource " + resource);
}
entities.add(existingContextEntity);
}
}
}
}
@@ -0,0 +1,21 @@
package net.hostsharing.hsadminng.credentials;
import io.micrometer.core.annotation.Timed;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson;
import org.springframework.data.repository.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface HsCredentialsRepository extends Repository<HsCredentialsEntity, UUID> {
@Timed("app.login.credentials.repo.findByUuid")
Optional<HsCredentialsEntity> findByUuid(final UUID uuid);
@Timed("app.login.credentials.repo.findByPerson")
List<HsCredentialsEntity> findByPerson(final HsOfficePerson personUuid);
@Timed("app.login.credentials.repo.save")
HsCredentialsEntity save(final HsCredentialsEntity entity);
}
@@ -4,6 +4,7 @@ package net.hostsharing.hsadminng.rbac.generator;
import static java.util.stream.Collectors.joining;
import static net.hostsharing.hsadminng.rbac.generator.StringWriter.indented;
import static net.hostsharing.hsadminng.rbac.generator.StringWriter.with;
import static net.hostsharing.hsadminng.rbac.generator.StringWriter.withQuoted;
public class RbacRestrictedViewGenerator {
private final RbacSpec rbacDef;
@@ -22,20 +23,21 @@ public class RbacRestrictedViewGenerator {
--changeset RbacRestrictedViewGenerator:${liquibaseTagPrefix}-rbac-RESTRICTED-VIEW runOnChange:true validCheckSum:ANY endDelimiter:--//
-- ----------------------------------------------------------------------------
call rbac.generateRbacRestrictedView('${rawTableName}',
$orderBy$
${orderBy}
$orderBy$,
$updates$
${updates}
$updates$);
${orderBy},
${updates}
);
--//
""",
with("liquibaseTagPrefix", liquibaseTagPrefix),
with("orderBy", indented(2, rbacDef.getOrderBySqlExpression().sql)),
with("updates", indented(2, rbacDef.getUpdatableColumns().stream()
.map(c -> c + " = new." + c)
.collect(joining(",\n")))),
withQuoted("orderBy", indented(2, rbacDef.getOrderBySqlExpression().sql)),
withQuoted("updates",
rbacDef.getUpdatableColumns().isEmpty()
? null
: indented(2, rbacDef.getUpdatableColumns().stream()
.map(c -> c + " = new." + c)
.collect(joining(",\n")))
),
with("rawTableName", rawTableName));
}
}
@@ -38,6 +38,8 @@ 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<>();
@@ -109,12 +111,23 @@ public class RbacSpec {
* @return
* the `this` instance itself to allow chained calls.
*/
public RbacSpec withUpdatableColumns(final String... columnNames) {
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.
@@ -188,7 +201,6 @@ public class RbacSpec {
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.
@@ -99,6 +99,11 @@ class RolesGrantsAndPermissionsGenerator {
.distinct()
.map(columnName -> "NEW." + columnName + " is distinct from OLD." + columnName)
.collect(joining( "\n or "));
if (updateConditions.isBlank()) {
// TODO_impl: RBAC rules for _rv do not yet work properly - check if this comment appears in generated output!
plPgSql.writeLn("-- no updatable columns found, skipping update trigger");
return;
}
plPgSql.writeLn("""
/*
Called from the AFTER UPDATE TRIGGER to re-wire the grants.
@@ -11,7 +11,11 @@ public class StringWriter {
private int indentLevel = 0;
static VarDef with(final String var, final String name) {
return new VarDef(var, name);
return new VarDef(var, name, false);
}
static VarDef withQuoted(final String var, final String name) {
return new VarDef(var, name, true);
}
void writeLn(final String text) {
@@ -97,7 +101,7 @@ public class StringWriter {
return indented(indentLevel, text);
}
record VarDef(String name, String value){}
record VarDef(String name, String value, boolean quoted) {}
private static final class VarReplacer {
@@ -111,13 +115,22 @@ public class StringWriter {
String apply(final String textToAppend) {
text = textToAppend;
stream(varDefs).forEach(varDef -> {
// TODO.impl: I actually want a case-independent search+replace but ...
// for which the substitution String can contain sequences of "${...}" to be replaced by further varDefs.
text = text.replace("${" + varDef.name() + "}", varDef.value());
text = text.replace("${" + varDef.name().toUpperCase() + "}", varDef.value());
text = text.replace("${" + varDef.name().toLowerCase() + "}", varDef.value());
replacePlaceholder(varDef.name(),
varDef.quoted()
? varDef.value() != null
? "$" + varDef.name() + "$\n" + varDef.value() + "\n$" + varDef.name() + "$"
: "null"
: varDef.value());
});
return text;
}
private void replacePlaceholder(final String name, final String value) {
// TODO.impl: I actually want a case-independent search+replace but ...
// for which the substitution String can contain sequences of "${...}" to be replaced by further varDefs.
text = text.replace("${" + name+ "}", value);
text = text.replace("${" + name.toUpperCase() + "}", value);
text = text.replace("${" + name.toLowerCase() + "}", value);
}
}
}