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);
}
}
}
@@ -0,0 +1,17 @@
openapi-processor-mapping: v2
options:
package-name: net.hostsharing.hsadminng.credentials.generated.api.v1
model-name-suffix: Resource
bean-validation: true
map:
result: org.springframework.http.ResponseEntity
types:
- type: array => java.util.List
- type: string:uuid => java.util.UUID
paths:
/api/hs/credentials/credentials/{credentialsUuid}:
null: org.openapitools.jackson.nullable.JsonNullable
@@ -0,0 +1,23 @@
openapi: 3.0.3
info:
title: Hostsharing hsadmin-ng API
version: v0
servers:
- url: http://localhost:8080
description: Local development default URL.
paths:
# Contexts
/api/hs/credentials/contexts:
$ref: "contexts.yaml"
# Credentials
/api/hs/credentials/credentials:
$ref: "api-paths.yaml"
/api/hs/credentials/credentials/{credentialsUuid}:
$ref: "credentials-with-uuid.yaml"
@@ -0,0 +1 @@
../auth.yaml
@@ -0,0 +1,21 @@
components:
schemas:
Context:
type: object
properties:
uuid:
type: string
format: uuid
type:
type: string
maxLength: 16
qualifier:
type: string
maxLength: 80
required:
- uuid
- type
- qualifier
@@ -0,0 +1,21 @@
get:
summary: Returns a list of all accessible contexts.
description: Returns the list of all contexts which are visible to the current subject or any of it's assumed roles.
tags:
- -contexts
operationId: getListOfContexts
parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
responses:
"200":
description: OK
content:
'application/json':
schema:
type: array
items:
$ref: 'context-schemas.yaml#/components/schemas/Context'
"401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: 'error-responses.yaml#/components/responses/Forbidden'
@@ -0,0 +1,91 @@
components:
schemas:
Credentials:
type: object
properties:
uuid:
type: string
format: uuid
twoFactorAuth:
type: string
telephonePassword:
type: string
emailAddress:
type: string
smsNumber:
type: string
active:
type: boolean
globalUid:
type: number
globalGid:
type: number
onboardingToken:
type: string
contexts:
type: array
items:
$ref: '-context-schemas.yaml#/components/schemas/Context'
required:
- uuid
- active
- contexts
additionalProperties: false
CredentialsPatch:
type: object
properties:
twoFactorAuth:
type: string
nullable: true
phonePassword:
type: string
nullable: true
emailAddress:
type: string
nullable: true
smsNumber:
type: string
nullable: true
active:
type: boolean
contexts:
type: array
items:
$ref: '-context-schemas.yaml#/components/schemas/Context'
additionalProperties: false
CredentialsInsert:
type: object
properties:
uuid:
type: string
format: uuid
twoFactorAuth:
type: string
telephonePassword:
type: string
emailAddress:
type: string
smsNumber:
type: string
active:
type: boolean
globalUid:
type: number
globalGid:
type: number
onboardingToken:
type: string
contexts:
type: array
items:
$ref: '-context-schemas.yaml#/components/schemas/Context'
required:
- uuid
- active
additionalProperties: false
@@ -0,0 +1,80 @@
get:
tags:
- -credentials
description: 'Fetch a single credentials its uuid, if visible for the current subject.'
operationId: getSingleCredentialsByUuid
parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
- name: CredentialsUuid
in: path
required: true
schema:
type: string
format: uuid
description: UUID of the credentials to fetch.
responses:
"200":
description: OK
content:
'application/json':
schema:
$ref: 'credentials-schemas.yaml#/components/schemas/Credentials'
"401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: 'error-responses.yaml#/components/responses/Forbidden'
patch:
tags:
- -credentials
description: 'Updates a single credentials identified by its uuid, if permitted for the current subject.'
operationId: patchCredentials
parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
- name: CredentialsUuid
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
'application/json':
schema:
$ref: 'credentials-schemas.yaml#/components/schemas/CredentialsPatch'
responses:
"200":
description: OK
content:
'application/json':
schema:
$ref: 'credentials-schemas.yaml#/components/schemas/Credentials'
"401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: 'error-responses.yaml#/components/responses/Forbidden'
delete:
tags:
- -credentials
description: 'Delete a single credentials identified by its uuid, if permitted for the current subject.'
operationId: deleteCredentialsByUuid
parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
- name: CredentialsUuid
in: path
required: true
schema:
type: string
format: uuid
description: UUID of the credentials to delete.
responses:
"204":
description: No Content
"401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: 'error-responses.yaml#/components/responses/Forbidden'
"404":
$ref: 'error-responses.yaml#/components/responses/NotFound'
@@ -0,0 +1,56 @@
get:
summary: Returns a list of all credentials.
description: Returns the list of all credentials which are visible to the current subject or any of it's assumed roles.
tags:
- -credentials
operationId: getListOfCredentialsByPersonUuid
parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
- name: personUuid
in: query
required: true
schema:
type: string
format: uuid
description: The UUID of the person, whose credentials are to be fetched.
responses:
"200":
description: OK
content:
'application/json':
schema:
type: array
items:
$ref: 'credentials-schemas.yaml#/components/schemas/Credentials'
"401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: 'error-responses.yaml#/components/responses/Forbidden'
post:
summary: Adds a new credentials.
tags:
- -credentials
operationId: postNewCredentials
parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
requestBody:
description: A JSON object describing the new credential.
required: true
content:
application/json:
schema:
$ref: 'credentials-schemas.yaml#/components/schemas/CredentialsInsert'
responses:
"201":
description: Created
content:
'application/json':
schema:
$ref: 'credentials-schemas.yaml#/components/schemas/Credentials'
"401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: 'error-responses.yaml#/components/responses/Forbidden'
"409":
$ref: 'error-responses.yaml#/components/responses/Conflict'
@@ -0,0 +1,40 @@
components:
responses:
NotFound:
description: The specified resource was not found.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Unauthorized:
description: The current subject is unknown or not authorized.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Forbidden:
description: The current subject or none of the assumed or roles is granted access to the resource.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Conflict:
description: The request could not be completed due to a conflict with the current state of the target resource.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
schemas:
Error:
type: object
properties:
code:
type: string
message:
type: string
required:
- code
- message
@@ -16,6 +16,7 @@ components:
- OPERATIONS_ALERT
- SUBSCRIBER
HsOfficeRelation:
type: object
properties:
@@ -91,13 +91,16 @@ end; $$;
-- ============================================================================
--changeset michael.hoennig:hs-global-historization-tx-create-historicization endDelimiter:--//
--changeset michael.hoennig:hs-global-historization-tx-create-historicization runOnChange:true validCheckSum:ANY endDelimiter:--//
-- ----------------------------------------------------------------------------
create or replace procedure base.tx_create_historicization(baseTable varchar)
create or replace procedure base.tx_create_historicization(
basetable varchar -- format 'schemaname.tablename'
)
language plpgsql as $$
declare
baseSchemaName varchar;
baseTableName varchar;
createHistTableSql varchar;
createTriggerSQL varchar;
viewName varchar;
@@ -106,14 +109,19 @@ declare
baseCols varchar;
begin
-- determine schema and pure table name
SELECT split_part(basetable, '.', 1),
split_part(basetable, '.', 2)
INTO baseSchemaName, baseTableName;
-- create the history table
createHistTableSql = '' ||
'CREATE TABLE ' || baseTable || '_ex (' ||
'CREATE TABLE ' || basetable || '_ex (' ||
' version_id serial PRIMARY KEY,' ||
' txid xid8 NOT NULL REFERENCES base.tx_context(txid),' ||
' trigger_op base.tx_operation NOT NULL,' ||
' alive boolean not null,' ||
' LIKE ' || baseTable ||
' LIKE ' || basetable ||
' EXCLUDING CONSTRAINTS' ||
' EXCLUDING STATISTICS' ||
')';
@@ -121,12 +129,12 @@ begin
execute createHistTableSql;
-- create the historical view
viewName = baseTable || '_hv';
exVersionsTable = baseTable || '_ex';
viewName = basetable || '_hv';
exVersionsTable = basetable || '_ex';
baseCols = (select string_agg(quote_ident(column_name), ', ')
from information_schema.columns
where table_schema = 'public'
and table_name = baseTable);
where table_schema = baseSchemaName
and table_name = baseTableName);
createViewSQL = format(
'CREATE OR REPLACE VIEW %1$s AS' ||
@@ -152,7 +160,7 @@ begin
-- "-9-" to put the trigger execution after any alphabetically lesser tx-triggers
createTriggerSQL = 'CREATE TRIGGER tx_9_historicize_tg' ||
' AFTER INSERT OR DELETE OR UPDATE ON ' || baseTable ||
' AFTER INSERT OR DELETE OR UPDATE ON ' || basetable ||
' FOR EACH ROW EXECUTE PROCEDURE base.tx_historicize_tf()';
execute createTriggerSQL;
@@ -0,0 +1,25 @@
--liquibase formatted sql
-- ============================================================================
--changeset michael.hoennig:base-array-functions-WITHOUT-NULL-VALUES endDelimiter:--//
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION base.without_null_values(arr anyarray)
RETURNS anyarray
AS $$
SELECT array_agg(e) FROM unnest(arr) AS e WHERE e IS NOT NULL
$$ LANGUAGE sql;
-- ============================================================================
--changeset michael.hoennig:base-array-functions-ADD-IF-NOT-NULLCREATE OR REPLACE FUNCTION add_if_not_null(anyarray, anyelement)
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION base.add_if_not_null(arr anyarray, val anyelement)
RETURNS anyarray
AS $$
SELECT CASE WHEN val IS NULL THEN arr ELSE arr || val END
$$ LANGUAGE sql;
@@ -536,7 +536,7 @@ $$;
-- ============================================================================
--changeset michael.hoennig:rbac-base-GRANTS endDelimiter:--//
--changeset michael.hoennig:rbac-base-GRANTS endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Table to store grants / role- or permission assignments to subjects or roles.
@@ -2,7 +2,7 @@
-- ============================================================================
--changeset michael.hoennig:rbac-context-DETERMINE endDelimiter:--//
--changeset michael.hoennig:rbac-context-DETERMINE runOnChange:true validCheckSum:ANY endDelimiter:--//
-- ----------------------------------------------------------------------------
create or replace function rbac.determineCurrentSubjectUuid(currentSubject varchar)
@@ -148,12 +148,12 @@ commit;
-- ============================================================================
--changeset michael.hoennig:rbac-global-GUEST-ROLE endDelimiter:--//
--changeset michael.hoennig:rbac-global-GUEST-ROLE runOnChange:true validCheckSum:ANY endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
A rbac.Global guest role.
*/
create or replace function rbac.globalglobalGuest(assumed boolean = true)
create or replace function rbac.global_GUEST(assumed boolean = true)
returns rbac.RoleDescriptor
returns null on null input
stable -- leakproof
@@ -161,10 +161,17 @@ create or replace function rbac.globalglobalGuest(assumed boolean = true)
select 'rbac.global', (select uuid from rbac.object where objectTable = 'rbac.global'), 'GUEST'::rbac.RoleType, assumed;
$$;
begin transaction;
call base.defineContext('creating role:rbac.global#global:guest', null, null, null);
select rbac.createRole(rbac.globalglobalGuest());
commit;
do language plpgsql $$
begin
call base.defineContext('creating role:rbac.global#global:guest', null, null, null);
begin
perform rbac.createRole(rbac.global_GUEST());
exception
when unique_violation then
null; -- ignore if it already exists from prev execution of this changeset
end;
end;
$$;
--//
@@ -0,0 +1,19 @@
--liquibase formatted sql
-- In a separate file to avoid changed checksums in the existing changsets.
-- I presume it's a bug in Liquibase that other changeset checksums are changed by new changesets in the same file
-- ============================================================================
--changeset michael.hoennig:hs-office-person-TEST-DATA-GENERATION-FOR-CREDENTIALS context:!without-test-data endDelimiter:--//
-- ----------------------------------------------------------------------------
do language plpgsql $$
begin
call hs_office.person_create_test_data('NP', null,'Hostmaster', 'Alex');
call hs_office.person_create_test_data('NP', null, 'Hostmaster', 'Fran');
call hs_office.person_create_test_data('NP', null, 'User', 'Drew');
call hs_office.person_create_test_data('NP', null, 'User', 'Test');
end;
$$;
--//
@@ -0,0 +1,8 @@
--liquibase formatted sql
-- ============================================================================
--changeset michael.hoennig:hs-credentials-SCHEMA endDelimiter:--//
-- ----------------------------------------------------------------------------
CREATE SCHEMA hs_credentials;
--//
@@ -0,0 +1,91 @@
--liquibase formatted sql
-- ============================================================================
--changeset michael.hoennig:hs-credentials-CREDENTIALS-TABLE endDelimiter:--//
-- ----------------------------------------------------------------------------
create table hs_credentials.credentials
(
uuid uuid PRIMARY KEY references rbac.subject (uuid) initially deferred,
version int not null default 0,
person_uuid uuid not null references hs_office.person(uuid),
active bool,
global_uid int unique, -- w/o
global_gid int unique, -- w/o
onboarding_token text, -- w/o
two_factor_auth text,
phone_password text,
email_address text,
sms_number text
);
--//
-- ============================================================================
--changeset michael.hoennig:hs-credentials-context-CONTEXT-TABLE endDelimiter:--//
-- ----------------------------------------------------------------------------
create table hs_credentials.context
(
uuid uuid PRIMARY KEY,
version int not null default 0,
type varchar(16),
qualifier varchar(80),
unique (type, qualifier)
);
--//
-- ============================================================================
--changeset michael.hoennig:hs-credentials-CONTEXT-IMMUTABLE-TRIGGER endDelimiter:--//
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION hs_credentials.prevent_context_update()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'Updates to hs_credentials.context are not allowed.';
END;
$$ LANGUAGE plpgsql;
-- Trigger to enforce immutability
CREATE TRIGGER context_immutable_trigger
BEFORE UPDATE ON hs_credentials.context
FOR EACH ROW EXECUTE FUNCTION hs_credentials.prevent_context_update();
--//
-- ============================================================================
--changeset michael.hoennig:hs_credentials-CONTEXT-MAPPING endDelimiter:--//
-- ----------------------------------------------------------------------------
create table hs_credentials.context_mapping
(
uuid uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
credentials_uuid uuid references hs_credentials.credentials(uuid) ON DELETE CASCADE,
context_uuid uuid references hs_credentials.context(uuid) ON DELETE RESTRICT
);
--//
-- ============================================================================
--changeset michael.hoennig:hs-hs_credentials-JOURNALS endDelimiter:--//
-- ----------------------------------------------------------------------------
call base.create_journal('hs_credentials.context_mapping');
call base.create_journal('hs_credentials.context');
call base.create_journal('hs_credentials.credentials');
--//
-- ============================================================================
--changeset michael.hoennig:hs_credentials-HISTORICIZATION endDelimiter:--//
-- ----------------------------------------------------------------------------
call base.tx_create_historicization('hs_credentials.context_mapping');
call base.tx_create_historicization('hs_credentials.context');
call base.tx_create_historicization('hs_credentials.credentials');
--//
@@ -0,0 +1,41 @@
### rbac credentialsContext
This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually.
```mermaid
%%{init:{'flowchart':{'htmlLabels':false}}}%%
flowchart TB
subgraph credentialsContext["`**credentialsContext**`"]
direction TB
style credentialsContext fill:#dd4901,stroke:#274d6e,stroke-width:8px
subgraph credentialsContext:roles[ ]
style credentialsContext:roles fill:#dd4901,stroke:white
role:credentialsContext:OWNER[[credentialsContext:OWNER]]
role:credentialsContext:ADMIN[[credentialsContext:ADMIN]]
role:credentialsContext:REFERRER[[credentialsContext:REFERRER]]
end
subgraph credentialsContext:permissions[ ]
style credentialsContext:permissions fill:#dd4901,stroke:white
perm:credentialsContext:INSERT{{credentialsContext:INSERT}}
perm:credentialsContext:UPDATE{{credentialsContext:UPDATE}}
perm:credentialsContext:DELETE{{credentialsContext:DELETE}}
perm:credentialsContext:SELECT{{credentialsContext:SELECT}}
end
end
%% granting roles to roles
role:credentialsContext:OWNER ==> role:credentialsContext:ADMIN
role:credentialsContext:ADMIN ==> role:credentialsContext:REFERRER
%% granting permissions to roles
role:rbac.global:ADMIN ==> perm:credentialsContext:INSERT
role:rbac.global:ADMIN ==> perm:credentialsContext:UPDATE
role:rbac.global:ADMIN ==> perm:credentialsContext:DELETE
role:rbac.global:REFERRER ==> perm:credentialsContext:SELECT
```
@@ -0,0 +1,68 @@
--liquibase formatted sql
-- ============================================================================
--changeset michael.hoennig:hs_credentials-credentials-TEST-DATA context:!without-test-data endDelimiter:--//
-- ----------------------------------------------------------------------------
do language plpgsql $$
declare
superuserAlexSubjectUuid uuid;
personAlexUuid uuid;
superuserFranSubjectUuid uuid;
personFranUuid uuid;
context_HSADMIN_prod hs_credentials.context;
context_SSH_internal hs_credentials.context;
context_MATRIX_internal hs_credentials.context;
begin
call base.defineContext('creating booking-project test-data', null, 'superuser-alex@hostsharing.net', 'rbac.global#global:ADMIN');
superuserAlexSubjectUuid = (SELECT uuid FROM rbac.subject WHERE name='superuser-alex@hostsharing.net');
personAlexUuid = (SELECT uuid FROM hs_office.person WHERE givenName='Alex');
superuserFranSubjectUuid = (SELECT uuid FROM rbac.subject WHERE name='superuser-fran@hostsharing.net');
personFranUuid = (SELECT uuid FROM hs_office.person WHERE givenName='Fran');
-- Add test contexts
INSERT INTO hs_credentials.context (uuid, type, qualifier) VALUES
('11111111-1111-1111-1111-111111111111', 'HSADMIN', 'prod')
RETURNING * INTO context_HSADMIN_prod;
INSERT INTO hs_credentials.context (uuid, type, qualifier) VALUES
('22222222-2222-2222-2222-222222222222', 'SSH', 'internal')
RETURNING * INTO context_SSH_internal;
INSERT INTO hs_credentials.context (uuid, type, qualifier) VALUES
('33333333-3333-3333-3333-333333333333', 'MATRIX', 'internal')
RETURNING * INTO context_MATRIX_internal;
-- grant general access to public credential contexts
-- TODO_impl: RBAC rules for _rv do not yet work properly
-- call rbac.grantPermissiontoRole(
-- rbac.createPermission(context_HSADMIN_prod.uuid, 'SELECT'),
-- rbac.global_GUEST());
-- call rbac.grantPermissiontoRole(
-- rbac.createPermission(context_SSH_internal.uuid, 'SELECT'),
-- rbac.global_ADMIN());
-- call rbac.grantPermissionToRole(
-- rbac.createPermission(context_MATRIX_internal.uuid, 'SELECT'),
-- rbac.global_ADMIN());
-- call rbac.grantRoleToRole(hs_credentials.context_REFERRER(context_SSH_internal), rbac.global_ADMIN());
-- call rbac.grantRoleToRole(hs_credentials.context_REFERRER(context_MATRIX_internal), rbac.global_ADMIN());
-- Add test credentials (linking to assumed rbac.subject UUIDs)
INSERT INTO hs_credentials.credentials (uuid, version, person_uuid, active, global_uid, global_gid, onboarding_token, two_factor_auth, phone_password, email_address, sms_number) VALUES
( superuserAlexSubjectUuid, 0, personAlexUuid, true, 1001, 1001, 'token-abc', 'otp-secret-1', 'phone-pw-1', 'alex@example.com', '111-222-3333'),
( superuserFranSubjectUuid, 0, personFranUuid, true, 1002, 1002, 'token-def', 'otp-secret-2', 'phone-pw-2', 'fran@example.com', '444-555-6666');
-- Map credentials to contexts
INSERT INTO hs_credentials.context_mapping (credentials_uuid, context_uuid) VALUES
(superuserAlexSubjectUuid, '11111111-1111-1111-1111-111111111111'), -- HSADMIN context
(superuserFranSubjectUuid, '11111111-1111-1111-1111-111111111111'), -- HSADMIN context
(superuserAlexSubjectUuid, '22222222-2222-2222-2222-222222222222'), -- SSH context
(superuserFranSubjectUuid, '22222222-2222-2222-2222-222222222222'), -- SSH context
(superuserAlexSubjectUuid, '33333333-3333-3333-3333-333333333333'), -- MATRIX context
(superuserFranSubjectUuid, '33333333-3333-3333-3333-333333333333'); -- MATRIX context
end; $$;
--//
@@ -4,5 +4,5 @@
-- ============================================================================
--changeset timotheus.pokorra:hs-integration-SCHEMA endDelimiter:--//
-- ----------------------------------------------------------------------------
CREATE SCHEMA hs_integration;
CREATE SCHEMA IF NOT EXISTS hs_integration;
--//
@@ -27,6 +27,8 @@ databaseChangeLog:
file: db/changelog/0-base/020-audit-log.sql
- include:
file: db/changelog/0-base/030-historization.sql
- include:
file: db/changelog/0-base/040-array-functions.sql
- include:
file: db/changelog/0-base/090-log-slow-queries-extensions.sql
@@ -100,6 +102,9 @@ databaseChangeLog:
- include:
file: db/changelog/5-hs-office/502-person/5028-hs-office-person-test-data.sql
context: "!without-test-data"
- include:
file: db/changelog/5-hs-office/502-person/5028-hs-office-person-test-data-for-credentials.sql
context: "!without-test-data"
- include:
file: db/changelog/5-hs-office/503-relation/5030-hs-office-relation.sql
- include:
@@ -212,19 +217,30 @@ databaseChangeLog:
file: db/changelog/9-hs-global/9000-statistics.sql
context: "!only-office"
- include:
file: db/changelog/9-hs-global/950-credentials/9500-hs-credentials-schema.sql
- include:
file: db/changelog/9-hs-global/950-credentials/9510-hs-credentials.sql
# TODO_impl: RBAC rules for _rv do not yet work properly
# - include:
# file: db/changelog/9-hs-global/950-credentials/9513-hs-credentials-rbac.sql
- include:
file: db/changelog/9-hs-global/950-credentials/9519-hs-credentials-test-data.sql
context: "!without-test-data"
- include:
file: db/changelog/9-hs-global/960-integrations/9600-hs-integration-schema.sql
- include:
file: db/changelog/9-hs-global/960-integrations/9610-integration-kimai.sql
- include:
file: db/changelog/9-hs-global/960-integrations/9620-integration-znuny.sql
- include:
file: db/changelog/9-hs-global/960-integrations/9630-integration-mlmmj.sql
- include:
file: db/changelog/9-hs-global/9800-cleanup.sql
context: "without-test-data"
- include:
file: db/changelog/9-hs-global/9100-hs-integration-schema.sql
- include:
file: db/changelog/9-hs-global/9110-integration-kimai.sql
- include:
file: db/changelog/9-hs-global/9120-integration-znuny.sql
- include:
file: db/changelog/9-hs-global/9130-integration-mlmmj.sql
- include:
file: db/changelog/9-hs-global/9999-liquibase-migration-test.sql
context: liquibase-migration-test