Story#5617: amend account module to Keycloak primary (#213)
Co-authored-by: Michael Hoennig <michael@hoennig.de> Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/213
This commit is contained in:
@@ -21,14 +21,14 @@ public class HsadminNgApplication {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
// TODO: to enable testing, we should use Spring config
|
||||
// TODO: to enable testing, we should use Spring config
|
||||
String allowedOrigins = System.getenv("ALLOWED_ORIGINS");
|
||||
if (allowedOrigins == null || allowedOrigins.length() <= 1) {
|
||||
allowedOrigins = "/**";
|
||||
}
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOrigins(allowedOrigins)
|
||||
.allowedMethods("GET", "PUT", "POST", "PATCH", "DELETE");
|
||||
.allowedOrigins(allowedOrigins)
|
||||
.allowedMethods("GET", "PUT", "POST", "PATCH", "DELETE");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
@@ -21,8 +22,9 @@ public class FakeJwtController {
|
||||
@PostMapping(value = "/fake-jwt/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||
@Timed("app.config.jwt.token")
|
||||
public ResponseEntity<Map<String, Object>> token(
|
||||
@RequestParam String username,
|
||||
@RequestParam String password,
|
||||
HttpServletRequest request,
|
||||
@RequestParam(name = "username", required = false) String username,
|
||||
@RequestParam(name = "password", required = false) String password,
|
||||
@RequestParam(defaultValue = "openid profile") String scope) {
|
||||
|
||||
log.info("Fake JWT: Issuing token for user: {}", username);
|
||||
|
||||
+6
-4
@@ -1,12 +1,13 @@
|
||||
package net.hostsharing.hsadminng.context;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.web.util.ContentCachingRequestWrapper;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
@@ -18,6 +19,7 @@ public class HttpServletRequestBodyCachingFilter extends OncePerRequestFilter {
|
||||
final HttpServletResponse response,
|
||||
final FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
filterChain.doFilter(new HttpServletRequestWithCachedBody(request), response);
|
||||
|
||||
filterChain.doFilter(new ContentCachingRequestWrapper(request), response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package net.hostsharing.hsadminng.context;
|
||||
|
||||
import org.springframework.util.StreamUtils;
|
||||
|
||||
import jakarta.servlet.ServletInputStream;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
|
||||
public class HttpServletRequestWithCachedBody extends HttpServletRequestWrapper {
|
||||
|
||||
private byte[] cachedBody;
|
||||
|
||||
public HttpServletRequestWithCachedBody(HttpServletRequest request) throws IOException {
|
||||
super(request);
|
||||
final var requestInputStream = request.getInputStream();
|
||||
this.cachedBody = StreamUtils.copyToByteArray(requestInputStream);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServletInputStream getInputStream() {
|
||||
return new HttpServletRequestBodyCache(this.cachedBody);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedReader getReader() {
|
||||
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedBody);
|
||||
return new BufferedReader(new InputStreamReader(byteArrayInputStream));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package net.hostsharing.hsadminng.hs.accounts;
|
||||
|
||||
import io.micrometer.core.annotation.Timed;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.val;
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.api.AccountApi;
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CurrentLoginUserResource;
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.HsOfficePersonResource;
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.AccountInsertResource;
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.AccountResource;
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.RbacSubjectResource;
|
||||
import net.hostsharing.hsadminng.config.MessageTranslator;
|
||||
import net.hostsharing.hsadminng.errors.ForbiddenException;
|
||||
import net.hostsharing.hsadminng.errors.Validate;
|
||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson;
|
||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
|
||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository;
|
||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType;
|
||||
import net.hostsharing.hsadminng.mapper.StrictMapper;
|
||||
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
|
||||
import net.hostsharing.hsadminng.rbac.context.Context;
|
||||
import net.hostsharing.hsadminng.rbac.grant.RbacGrantRepository;
|
||||
import net.hostsharing.hsadminng.rbac.grant.RbacGrantService;
|
||||
import net.hostsharing.hsadminng.rbac.role.RbacRoleRepository;
|
||||
import net.hostsharing.hsadminng.rbac.role.RbacRoleService;
|
||||
import net.hostsharing.hsadminng.rbac.role.RbacRoleType;
|
||||
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
|
||||
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
|
||||
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
|
||||
|
||||
import jakarta.persistence.EntityNotFoundException;
|
||||
import jakarta.validation.ValidationException;
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
import static java.util.Optional.of;
|
||||
|
||||
@RestController
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@SecurityRequirement(name = "bearerAuth")
|
||||
public class HsAccountController implements AccountApi {
|
||||
|
||||
@Autowired
|
||||
private Context context;
|
||||
|
||||
@Autowired
|
||||
private EntityManagerWrapper em;
|
||||
|
||||
@Autowired
|
||||
private StrictMapper mapper;
|
||||
|
||||
@Autowired
|
||||
private MessageTranslator messageTranslator;
|
||||
|
||||
@Autowired
|
||||
private HsOfficePersonRealRepository realPersonRepo;
|
||||
|
||||
@Autowired
|
||||
private HsAccountRepository accountRepo;
|
||||
|
||||
@Autowired
|
||||
private RbacSubjectRepository rbacSubjectRepo;
|
||||
|
||||
@Autowired
|
||||
private RbacRoleRepository rbacRoleRepo;
|
||||
|
||||
@Autowired
|
||||
private RbacGrantRepository rbacGrantRepo;
|
||||
|
||||
@Autowired
|
||||
private RbacRoleService rbacRoleService;
|
||||
|
||||
@Autowired
|
||||
private RbacGrantService rbacGrantService;
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
@Timed("app.accounts.account.getSingleAccountByUuid")
|
||||
public ResponseEntity<AccountResource> getSingleAccountByUuid(final UUID accountUuid) {
|
||||
|
||||
context.define(); // without assumed roles, otherwise we cannot access the real subject anymore
|
||||
|
||||
val accountEntity = accountRepo.findByUuid(accountUuid);
|
||||
if (accountEntity.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
val result = mapper.map(
|
||||
accountEntity.get(), AccountResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
@Timed("app.accounts.account.getListIfAccountByPersonUuid")
|
||||
public ResponseEntity<List<AccountResource>> getListIfAccount(
|
||||
final String assumedRoles,
|
||||
final UUID personUuid
|
||||
) {
|
||||
context.assumeRoles(assumedRoles);
|
||||
|
||||
val account = personUuid == null
|
||||
? accountRepo.findByCurrentSubject()
|
||||
: findByPersonUuid(personUuid);
|
||||
val result = mapper.mapList(
|
||||
account, AccountResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@Timed("app.accounts.account.postNewAccount")
|
||||
public ResponseEntity<AccountResource> postNewAccount(
|
||||
final AccountInsertResource body
|
||||
) {
|
||||
// Only with exactly 1 assumed role, we can do explicit grants, because the assumed role is used as 'grantor'.
|
||||
// Otherwise, only the granting user could revoke the grant.
|
||||
context.assumeRoles("rbac.global#global:ADMIN");
|
||||
|
||||
val originalLoginContext = new LoginContext(context);
|
||||
|
||||
// TODO.spec: for now, only global admins can create new accounts, auto-creation has to be specified. which person?
|
||||
if (!originalLoginContext.isGlobalAdmin) {
|
||||
throw new ForbiddenException(
|
||||
messageTranslator.translate(
|
||||
//"account.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person",
|
||||
"account.access-denied-to-create-new-account-subject-{0}-is-not-a-global-admin",
|
||||
originalLoginContext.subjectUuid));
|
||||
}
|
||||
|
||||
// first create and save the subject to get its UUID
|
||||
val newlySavedSubject = createSubject(body.getSubjectName());
|
||||
|
||||
// determine the assigned person while we still have global-admin privileges
|
||||
val newAccountEntity = mapper.map(
|
||||
body, HsAccountEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
|
||||
validateOnCreate(originalLoginContext, newAccountEntity);
|
||||
|
||||
// grant the person's ADMIN role to the new subject
|
||||
rbacGrantService.grant(rbacRoleService.rbacRole(newAccountEntity.getPerson(), RbacRoleType.ADMIN))
|
||||
.to(newlySavedSubject);
|
||||
|
||||
// switch to the new subject to get access to its own subject RBAC object
|
||||
context.define("activate newly created self-service subject", null, body.getSubjectName(), null);
|
||||
|
||||
// afterward, create and save the account entity with the subject's UUID
|
||||
newAccountEntity.setSubject(em.merge(newlySavedSubject)); // attached to EM by the new subject
|
||||
em.persist(newAccountEntity); // newAccountEntity.uuid == newlySavedSubject.uuid => do not use repository!
|
||||
|
||||
// return the new account as a resource
|
||||
val uri =
|
||||
MvcUriComponentsBuilder.fromController(getClass())
|
||||
.path("/api/hs/accounts/accounts/{id}")
|
||||
.buildAndExpand(newAccountEntity.getUuid())
|
||||
.toUri();
|
||||
val newAccountResource = mapper.map(
|
||||
newAccountEntity, AccountResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
|
||||
return ResponseEntity.created(uri).body(newAccountResource);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@Timed("app.accounts.account.deleteAccountByUuid")
|
||||
public ResponseEntity<Void> deleteAccountByUuid(final UUID accountUuid) {
|
||||
context.define(); // without assumed roles, otherwise we cannot access the subject anymore
|
||||
val accountEntity = em.getReference(HsAccountEntity.class, accountUuid);
|
||||
validateOnDelete(accountEntity);
|
||||
em.flush();
|
||||
em.remove(accountEntity);
|
||||
em.remove(accountEntity.getSubject());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@Timed("app.accounts.account.getCurrentLoginUser")
|
||||
public ResponseEntity<CurrentLoginUserResource> getCurrentLoginUser() {
|
||||
|
||||
// define a context without assumed roles, otherwise we cannot access the subject anymore
|
||||
context.define();
|
||||
|
||||
// fetch the data
|
||||
val currentSubjectUuid = context.fetchCurrentSubjectUuid();
|
||||
val currentSubject = rbacSubjectRepo.findByUuid(currentSubjectUuid);
|
||||
val person = accountRepo.findByUuid(currentSubjectUuid).orElseThrow().getPerson();
|
||||
|
||||
final boolean isGlobalAdmin = context.isGlobalAdmin();
|
||||
|
||||
// finally, return the result
|
||||
val result = currentLoginUserResponse(currentSubject, person, isGlobalAdmin);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
private void validateOnCreate(final LoginContext originalLoginContext, final HsAccountEntity newAccountEntity) {
|
||||
validateReferencedPersonToBeANaturalPerson(newAccountEntity);
|
||||
}
|
||||
|
||||
private void validateOnDelete(final HsAccountEntity current) {
|
||||
// TODO.spec Task#5637: still needed? can the own account even be removed, even the last one?
|
||||
}
|
||||
|
||||
private void validateReferencedPersonToBeANaturalPerson(final HsAccountEntity accountEntity) {
|
||||
val referredPerson = accountEntity.getPerson();
|
||||
if ( referredPerson.getPersonType() != HsOfficePersonType.NATURAL_PERSON) {
|
||||
throw new ValidationException(
|
||||
messageTranslator.translate(
|
||||
"account.only-natural-persons-allowed-but-{0}-is-{1}",
|
||||
referredPerson.getUuid(), referredPerson.getPersonType().name()));
|
||||
}
|
||||
}
|
||||
|
||||
private RealSubjectEntity createSubject(final String subjectName) {
|
||||
val rbacSubjectEntity = RbacSubjectEntity.builder().name(subjectName).build();
|
||||
val newRbacSubject = rbacSubjectRepo.create(rbacSubjectEntity);
|
||||
return em.find(RealSubjectEntity.class, newRbacSubject.getUuid());
|
||||
}
|
||||
|
||||
private List<HsAccountEntity> findByPersonUuid(final UUID personUuid) {
|
||||
val person = realPersonRepo.findByUuid(personUuid).orElseThrow(
|
||||
() -> new EntityNotFoundException(
|
||||
messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", personUuid)
|
||||
)
|
||||
|
||||
);
|
||||
return accountRepo.findByPerson(person);
|
||||
}
|
||||
|
||||
|
||||
private CurrentLoginUserResource currentLoginUserResponse(
|
||||
final RbacSubjectEntity currentSubject,
|
||||
final HsOfficePerson<?> person,
|
||||
final boolean isGlobalAdmin) {
|
||||
val result = new CurrentLoginUserResource();
|
||||
result.setSubject(mapper.map(currentSubject, RbacSubjectResource.class));
|
||||
result.setPerson(mapper.map(person, HsOfficePersonResource.class));
|
||||
result.setGlobalAdmin(isGlobalAdmin);
|
||||
return result;
|
||||
}
|
||||
|
||||
final BiConsumer<HsAccountEntity, AccountResource> ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
|
||||
of(entity.getSubject()).ifPresent(
|
||||
subject -> resource.setSubjectName(subject.getName())
|
||||
);
|
||||
of(entity.getPerson()).ifPresent(
|
||||
person -> resource.setPerson(
|
||||
mapper.map(person, HsOfficePersonResource.class)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
final BiConsumer<AccountInsertResource, HsAccountEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
|
||||
|
||||
Validate.validate("person, person.uuid").exactlyOne(resource.getPerson(), resource.getPersonUuid());
|
||||
if ( resource.getPersonUuid() != null) {
|
||||
entity.setPerson(realPersonRepo.findByUuid(resource.getPersonUuid()).orElseThrow(
|
||||
() -> new NoSuchElementException("cannot find Person by 'person.uuid': " + resource.getPersonUuid())
|
||||
));
|
||||
} else {
|
||||
entity.setPerson(realPersonRepo.save(
|
||||
mapper.map(resource.getPerson(), HsOfficePersonRealEntity.class)
|
||||
) );
|
||||
}
|
||||
|
||||
val person = realPersonRepo.findByUuid(entity.getPerson().getUuid()).orElseThrow(
|
||||
() -> new EntityNotFoundException(
|
||||
messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", resource.getPersonUuid())
|
||||
)
|
||||
);
|
||||
entity.setPerson(person);
|
||||
};
|
||||
|
||||
@AllArgsConstructor
|
||||
private class LoginContext {
|
||||
final HsAccountEntity account;
|
||||
final boolean isGlobalAdmin;
|
||||
final UUID subjectUuid;
|
||||
|
||||
public LoginContext(final Context context) {
|
||||
subjectUuid = context.fetchCurrentSubjectUuid();
|
||||
account = accountRepo.findByUuid(subjectUuid)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"subject " + context.fetchCurrentSubject() + " has no account"));
|
||||
isGlobalAdmin = context.isGlobalAdmin();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package net.hostsharing.hsadminng.hs.accounts;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.ValidationException;
|
||||
|
||||
import lombok.*;
|
||||
import net.hostsharing.hsadminng.hash.LdapArgon2Hash;
|
||||
import net.hostsharing.hsadminng.hash.LdapSshaHash;
|
||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
|
||||
import net.hostsharing.hsadminng.persistence.BaseEntity; // Assuming BaseEntity exists
|
||||
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
|
||||
import net.hostsharing.hsadminng.repr.Stringify;
|
||||
import net.hostsharing.hsadminng.repr.Stringifyable;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static net.hostsharing.hsadminng.repr.Stringify.stringify;
|
||||
|
||||
@Entity
|
||||
@Table(schema = "hs_accounts", name = "account")
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class HsAccountEntity implements BaseEntity<HsAccountEntity>, Stringifyable {
|
||||
|
||||
protected static Stringify<HsAccountEntity> stringify = stringify(HsAccountEntity.class, "account")
|
||||
.withProp(e -> e.getSubject().getName())
|
||||
.quotedValues(false);
|
||||
|
||||
@Id
|
||||
private UUID uuid;
|
||||
|
||||
@MapsId
|
||||
@OneToOne(optional = false, fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@JoinColumn(name = "uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
|
||||
// Must be the real subject, not the RBAC-subject,
|
||||
// so that representative persons can access accounts+subjects of represented persons.
|
||||
// Otherwise, we would also need to allow RBAC grants to subject roles.
|
||||
// This also means that each access has to be checked explicitly (same subject or represented subject).
|
||||
private RealSubjectEntity subject;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
|
||||
private HsOfficePersonRealEntity person; // TODO.spec: Do we need ReBAC-Support for AccountEntity?
|
||||
|
||||
@Version
|
||||
private int version;
|
||||
|
||||
@Column
|
||||
private Integer globalUid;
|
||||
|
||||
@Column
|
||||
private Integer globalGid;
|
||||
|
||||
public void setSubject(final RealSubjectEntity subject) {
|
||||
this.uuid = subject.getUuid();
|
||||
this.subject = subject;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toShortString() {
|
||||
return subject.getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return stringify.apply(this);
|
||||
}
|
||||
|
||||
private static void validatePasswordHash(final String passwordHash) {
|
||||
|
||||
if (!LdapSshaHash.isValid(passwordHash) && !LdapArgon2Hash.isValid(passwordHash)) {
|
||||
throw new ValidationException("passwordHash must be SSHA or ARGON2 hash valid for LDAP");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package net.hostsharing.hsadminng.hs.accounts;
|
||||
|
||||
import io.micrometer.core.annotation.Timed;
|
||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface HsAccountRepository extends Repository<HsAccountEntity, UUID> {
|
||||
|
||||
@Timed("app.login.account.repo.findByUuid")
|
||||
Optional<HsAccountEntity> findByUuid(final UUID uuid);
|
||||
|
||||
@Timed("app.login.account.repo.findByPerson")
|
||||
List<HsAccountEntity> findByPerson(final HsOfficePerson<?> personUuid);
|
||||
|
||||
@Timed("app.login.account.repo.findByCurrentSubject")
|
||||
@Query(nativeQuery = true, value = """
|
||||
WITH RECURSIVE
|
||||
same_person AS (
|
||||
SELECT own_account.person_uuid
|
||||
FROM hs_accounts.account own_account
|
||||
WHERE own_account.uuid = rbac.currentSubjectUuid()
|
||||
),
|
||||
represented_persons AS (
|
||||
SELECT relation.anchorUuid person_uuid
|
||||
FROM hs_office.relation relation
|
||||
WHERE relation.type = 'REPRESENTATIVE'
|
||||
AND relation.holderUuid IN (SELECT person_uuid FROM same_person)
|
||||
)
|
||||
SELECT DISTINCT account.*
|
||||
FROM hs_accounts.account account
|
||||
WHERE account.person_uuid IN (SELECT person_uuid FROM same_person)
|
||||
OR account.person_uuid IN (SELECT person_uuid FROM represented_persons)
|
||||
""")
|
||||
List<HsAccountEntity> findByCurrentSubject();
|
||||
|
||||
@Timed("app.login.account.repo.save")
|
||||
HsAccountEntity save(final HsAccountEntity entity);
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
package net.hostsharing.hsadminng.hs.accounts;
|
||||
|
||||
import io.micrometer.core.annotation.Timed;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.val;
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.api.ProfileApi;
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CurrentLoginUserResource;
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.HsOfficePersonResource;
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfileInsertResource;
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfilePatchResource;
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfileResource;
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.RbacSubjectResource;
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ScopeResource;
|
||||
import net.hostsharing.hsadminng.config.MessageTranslator;
|
||||
import net.hostsharing.hsadminng.errors.ForbiddenException;
|
||||
import net.hostsharing.hsadminng.errors.Validate;
|
||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson;
|
||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
|
||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository;
|
||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType;
|
||||
import net.hostsharing.hsadminng.mapper.StrictMapper;
|
||||
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
|
||||
import net.hostsharing.hsadminng.rbac.context.Context;
|
||||
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
|
||||
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
|
||||
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
|
||||
|
||||
import jakarta.persistence.EntityNotFoundException;
|
||||
import jakarta.validation.ValidationException;
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static java.util.Optional.of;
|
||||
|
||||
@RestController
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@SecurityRequirement(name = "bearerAuth")
|
||||
public class HsProfileController implements ProfileApi {
|
||||
|
||||
@Autowired
|
||||
private Context context;
|
||||
|
||||
@Autowired
|
||||
private EntityManagerWrapper em;
|
||||
|
||||
@Autowired
|
||||
private StrictMapper mapper;
|
||||
|
||||
@Autowired
|
||||
private ScopeResourceToEntityMapper scopeMapper;
|
||||
|
||||
@Autowired
|
||||
private MessageTranslator messageTranslator;
|
||||
|
||||
@Autowired
|
||||
private HsOfficePersonRealRepository realPersonRepo;
|
||||
|
||||
@Autowired
|
||||
private HsProfileRepository profileRepo;
|
||||
|
||||
@Autowired
|
||||
private RbacSubjectRepository rbacSubjectRepo;
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
@Timed("app.accounts.profile.getSingleProfileByUuid")
|
||||
public ResponseEntity<ProfileResource> getSingleProfileByUuid(final UUID profileUuid) {
|
||||
|
||||
context.define(); // without assumed roles, otherwise we cannot access the subject anymore
|
||||
|
||||
val profileEntity = profileRepo.findByUuid(profileUuid);
|
||||
if (profileEntity.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
val result = mapper.map(
|
||||
profileEntity.get(), ProfileResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
@Timed("app.accounts.profile.getListOfProfileByPersonUuid")
|
||||
public ResponseEntity<List<ProfileResource>> getListOfProfile(
|
||||
final String assumedRoles,
|
||||
final UUID personUuid
|
||||
) {
|
||||
context.assumeRoles(assumedRoles);
|
||||
|
||||
val profile = personUuid == null
|
||||
? profileRepo.findByCurrentSubject()
|
||||
: findByPersonUuid(personUuid);
|
||||
val result = mapper.mapList(
|
||||
profile, ProfileResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@Timed("app.accounts.profile.postNewProfile")
|
||||
public ResponseEntity<ProfileResource> postNewProfile(
|
||||
final ProfileInsertResource body
|
||||
) {
|
||||
context.define(); // without assumed roles, otherwise we cannot access the subject anymore
|
||||
final LoginContext originalLoginContext = new LoginContext(context);
|
||||
|
||||
// first create and save the subject to get its UUID
|
||||
val newlySavedSubject = createSubject(body.getNickname());
|
||||
|
||||
// switch to the new subject to get access to its own subject RBAC object
|
||||
context.define("activate newly created self-service subject", null, body.getNickname(), null);
|
||||
|
||||
// afterward, create and save the profile entity with the subject's UUID
|
||||
val newProfileEntity = mapper.map(
|
||||
body, HsProfileEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
|
||||
validateOnCreate(originalLoginContext, newProfileEntity);
|
||||
|
||||
newProfileEntity.setSubject(em.merge(newlySavedSubject)); // attached to EM by the new subject
|
||||
em.persist(newProfileEntity); // newProfileEntity.uuid == newlySavedSubject.uuid => do not use repository!
|
||||
|
||||
// return the new profile as a resource
|
||||
val uri =
|
||||
MvcUriComponentsBuilder.fromController(getClass())
|
||||
.path("/api/hs/accounts/profiles/{id}")
|
||||
.buildAndExpand(newProfileEntity.getUuid())
|
||||
.toUri();
|
||||
val newProfileResource = mapper.map(
|
||||
newProfileEntity, ProfileResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
|
||||
return ResponseEntity.created(uri).body(newProfileResource);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@Timed("app.accounts.profile.deleteProfileByUuid")
|
||||
public ResponseEntity<Void> deleteProfileByUuid(final UUID profileUuid) {
|
||||
context.define(); // without assumed roles, otherwise we cannot access the subject anymore
|
||||
val profileEntity = em.getReference(HsProfileEntity.class, profileUuid);
|
||||
profileEntity.getScopes().clear();
|
||||
validateOnDelete(profileEntity);
|
||||
em.flush();
|
||||
em.remove(profileEntity);
|
||||
em.remove(profileEntity.getSubject());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@Timed("app.accounts.profile.patchProfile")
|
||||
public ResponseEntity<ProfileResource> patchProfile(
|
||||
final UUID profileUuid,
|
||||
final ProfilePatchResource body
|
||||
) {
|
||||
context.define(); // without assumed roles, otherwise we cannot access the subject anymore
|
||||
final LoginContext originalLoginContext = new LoginContext(context);
|
||||
|
||||
val current = profileRepo.findByUuid(profileUuid).orElseThrow();
|
||||
|
||||
validateBeforePatch(originalLoginContext, current, body);
|
||||
new HsProfileEntityPatcher(scopeMapper, current).apply(body);
|
||||
validateOnUpdate(current);
|
||||
|
||||
val saved = profileRepo.save(current);
|
||||
val mapped = mapper.map(
|
||||
saved, ProfileResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
|
||||
return ResponseEntity.ok(mapped);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@Timed("app.accounts.profile.getCurrentLoginUser")
|
||||
public ResponseEntity<CurrentLoginUserResource> getCurrentLoginUser() {
|
||||
|
||||
// define a context without assumed roles, otherwise we cannot access the subject anymore
|
||||
context.define();
|
||||
|
||||
// fetch the data
|
||||
val currentSubjectUuid = context.fetchCurrentSubjectUuid();
|
||||
val currentSubject = rbacSubjectRepo.findByUuid(currentSubjectUuid);
|
||||
val person = profileRepo.findByUuid(currentSubjectUuid).orElseThrow().getPerson();
|
||||
|
||||
final boolean isGlobalAdmin = context.isGlobalAdmin();
|
||||
|
||||
// finally, return the result
|
||||
val result = currentLoginUserResponse(currentSubject, person, isGlobalAdmin);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
private void validateBeforePatch(final LoginContext originalLoginContext, final HsProfileEntity current, final ProfilePatchResource body) {
|
||||
validateReferencedPersonToBeRepresentedByLoginUserPerson(originalLoginContext, current);
|
||||
|
||||
if (!context.isGlobalAdmin() && !current.isActive() && body.getActive())
|
||||
throw new ForbiddenException("Only global admins are allowed to activate an inactive profile");
|
||||
}
|
||||
|
||||
private void validateOnCreate(final LoginContext originalLoginContext, final HsProfileEntity newProfileEntity) {
|
||||
validateReferencedPersonToBeRepresentedByLoginUserPerson(originalLoginContext, newProfileEntity);
|
||||
validateNormalUsersOnlyAccessPublicScopes(newProfileEntity);
|
||||
validateNaturalPersonRequirementOfScopes(newProfileEntity);
|
||||
}
|
||||
|
||||
private void validateOnUpdate(final HsProfileEntity current) {
|
||||
validateNormalUsersOnlyAccessPublicScopes(current);
|
||||
validateNaturalPersonRequirementOfScopes(current);
|
||||
validateOwnHsadminProfileMustNotBeRemoved(current);
|
||||
}
|
||||
|
||||
private void validateOnDelete(final HsProfileEntity profileEntity) {
|
||||
validateOwnHsadminProfileMustNotBeRemoved(profileEntity);
|
||||
}
|
||||
|
||||
private void validateReferencedPersonToBeRepresentedByLoginUserPerson(final LoginContext originalLoginContext, final HsProfileEntity profileEntity) {
|
||||
if (originalLoginContext.isGlobalAdmin) {
|
||||
return;
|
||||
}
|
||||
val referredPersonUuid = profileEntity.getPerson().getUuid();
|
||||
val loginPersonUuid = originalLoginContext.profile.getPerson().getUuid();
|
||||
val representedPersonUuids = realPersonRepo.findPersonsRepresentedByPersonWithUuid(loginPersonUuid)
|
||||
.stream().map(HsOfficePerson::getUuid).toList();
|
||||
if ( !representedPersonUuids.contains(referredPersonUuid)) {
|
||||
throw new ForbiddenException(
|
||||
messageTranslator.translate(
|
||||
"profile.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person",
|
||||
loginPersonUuid));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateNormalUsersOnlyAccessPublicScopes(final HsProfileEntity newProfileEntity) {
|
||||
val forbiddenScopes = newProfileEntity.getScopes().stream()
|
||||
.filter(c -> !c.isPublicAccess() && !context.isGlobalAdmin() )
|
||||
.toList();
|
||||
if (!forbiddenScopes.isEmpty()) {
|
||||
throw new ForbiddenException(
|
||||
messageTranslator.translate(
|
||||
"profile.access-denied-for-scopes-{0}",
|
||||
toDisplay(forbiddenScopes)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateNaturalPersonRequirementOfScopes(final HsProfileEntity newProfileEntity) {
|
||||
if (newProfileEntity.getPerson().getPersonType().equals(HsOfficePersonType.NATURAL_PERSON)) {
|
||||
return;
|
||||
}
|
||||
val scopesWhichRequireNaturalPerson = newProfileEntity.getScopes().stream()
|
||||
.filter(HsProfileScope::isOnlyForNaturalPersons)
|
||||
.toList();
|
||||
if (!scopesWhichRequireNaturalPerson.isEmpty()) {
|
||||
throw new ValidationException(
|
||||
messageTranslator.translate(
|
||||
"profile.scope-requires-natural-person-{0}",
|
||||
toDisplay(scopesWhichRequireNaturalPerson)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateOwnHsadminProfileMustNotBeRemoved(final HsProfileEntity newProfileEntity) {
|
||||
if (!newProfileEntity.getSubject().getUuid().equals(context.fetchCurrentSubjectUuid())) {
|
||||
return;
|
||||
}
|
||||
val hsadminProfileScope = newProfileEntity.getScopes().stream()
|
||||
.filter(HsProfileScope::isHsadminScope)
|
||||
.toList();
|
||||
if (hsadminProfileScope.isEmpty()) {
|
||||
throw new ValidationException(
|
||||
messageTranslator.translate(
|
||||
"profile.own-hsadmin-profile-must-not-be-removed"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private static String toDisplay(final List<HsProfileScopeRealEntity> scopesWhichRequireNaturalPerson) {
|
||||
return scopesWhichRequireNaturalPerson.stream()
|
||||
.map(HsProfileScope::toShortString)
|
||||
.sorted()
|
||||
.map(s -> "'" + s + "'")
|
||||
.collect(Collectors.joining(", "));
|
||||
}
|
||||
|
||||
private RealSubjectEntity createSubject(final String nickname) {
|
||||
val rbacSubjectEntity = RbacSubjectEntity.builder().name(nickname).build();
|
||||
val newRbacSubject = rbacSubjectRepo.create(rbacSubjectEntity);
|
||||
return em.find(RealSubjectEntity.class, newRbacSubject.getUuid());
|
||||
}
|
||||
|
||||
private List<HsProfileEntity> findByPersonUuid(final UUID personUuid) {
|
||||
val person = realPersonRepo.findByUuid(personUuid).orElseThrow(
|
||||
() -> new EntityNotFoundException(
|
||||
messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", personUuid)
|
||||
)
|
||||
|
||||
);
|
||||
return profileRepo.findByPerson(person);
|
||||
}
|
||||
|
||||
|
||||
private CurrentLoginUserResource currentLoginUserResponse(
|
||||
final RbacSubjectEntity currentSubject,
|
||||
final HsOfficePerson<?> person,
|
||||
final boolean isGlobalAdmin) {
|
||||
val result = new CurrentLoginUserResource();
|
||||
result.setSubject(mapper.map(currentSubject, RbacSubjectResource.class));
|
||||
result.setPerson(mapper.map(person, HsOfficePersonResource.class));
|
||||
result.setGlobalAdmin(isGlobalAdmin);
|
||||
return result;
|
||||
}
|
||||
|
||||
final BiConsumer<HsProfileEntity, ProfileResource> ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
|
||||
of(entity.getSubject()).ifPresent(
|
||||
subject -> resource.setNickname(subject.getName())
|
||||
);
|
||||
of(entity.getPerson()).ifPresent(
|
||||
person -> resource.setPerson(
|
||||
mapper.map(person, HsOfficePersonResource.class)
|
||||
)
|
||||
);
|
||||
|
||||
resource.setScopes(mapToValidScopeResources(entity));
|
||||
};
|
||||
|
||||
private List<ScopeResource> mapToValidScopeResources(final HsProfileEntity entity) {
|
||||
var allScopes = mapper.mapList(entity.getScopes().stream().toList(), ScopeResource.class);
|
||||
return allScopes.stream()
|
||||
.filter(scope -> !scope.getOnlyForNaturalPersons() ||
|
||||
entity.getPerson().getPersonType() == HsOfficePersonType.NATURAL_PERSON)
|
||||
.toList();
|
||||
}
|
||||
|
||||
final BiConsumer<ProfileInsertResource, HsProfileEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
|
||||
|
||||
Validate.validate("person, person.uuid").exactlyOne(resource.getPerson(), resource.getPersonUuid());
|
||||
if ( resource.getPersonUuid() != null) {
|
||||
entity.setPerson(realPersonRepo.findByUuid(resource.getPersonUuid()).orElseThrow(
|
||||
() -> new NoSuchElementException("cannot find Person by 'person.uuid': " + resource.getPersonUuid())
|
||||
));
|
||||
} else {
|
||||
entity.setPerson(realPersonRepo.save(
|
||||
mapper.map(resource.getPerson(), HsOfficePersonRealEntity.class)
|
||||
) );
|
||||
}
|
||||
|
||||
val person = realPersonRepo.findByUuid(entity.getPerson().getUuid()).orElseThrow(
|
||||
() -> new EntityNotFoundException(
|
||||
messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", resource.getPersonUuid())
|
||||
)
|
||||
);
|
||||
entity.setPerson(person);
|
||||
entity.setScopes(scopeMapper.mapProfileToScopeEntities(resource.getScopes()));
|
||||
entity.setPassword(resource.getPassword());
|
||||
};
|
||||
|
||||
@AllArgsConstructor
|
||||
private class LoginContext {
|
||||
final HsProfileEntity profile;
|
||||
final boolean isGlobalAdmin;
|
||||
|
||||
public LoginContext(final Context context) {
|
||||
val subjectUuid = context.fetchCurrentSubjectUuid();
|
||||
profile = profileRepo.findByUuid(subjectUuid)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"subject " + context.fetchCurrentSubject() + " has no profile"));
|
||||
isGlobalAdmin = context.isGlobalAdmin();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package net.hostsharing.hsadminng.hs.accounts;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.ValidationException;
|
||||
|
||||
import lombok.*;
|
||||
import net.hostsharing.hsadminng.hash.HashGenerator;
|
||||
import net.hostsharing.hsadminng.hash.LdapArgon2Hash;
|
||||
import net.hostsharing.hsadminng.hash.LdapSshaHash;
|
||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
|
||||
import net.hostsharing.hsadminng.persistence.BaseEntity; // Assuming BaseEntity exists
|
||||
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
|
||||
import net.hostsharing.hsadminng.repr.Stringify;
|
||||
import net.hostsharing.hsadminng.repr.Stringifyable;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
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_accounts", name = "profile")
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class HsProfileEntity implements BaseEntity<HsProfileEntity>, Stringifyable {
|
||||
|
||||
protected static Stringify<HsProfileEntity> stringify = stringify(HsProfileEntity.class, "profile")
|
||||
.withProp(HsProfileEntity::isActive)
|
||||
.withProp(HsProfileEntity::getEmailAddress)
|
||||
.withProp(HsProfileEntity::getTotpSecrets)
|
||||
.withProp(HsProfileEntity::getPhonePassword)
|
||||
.withProp(HsProfileEntity::getSmsNumber)
|
||||
.quotedValues(false);
|
||||
|
||||
@Id
|
||||
private UUID uuid;
|
||||
|
||||
@MapsId
|
||||
@OneToOne(optional = false, fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@JoinColumn(name = "uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
|
||||
// Must be the real subject, so that representative persons can access profiles+subjects of represented persons.
|
||||
// Otherwise, we would also need to allow RBAC grants to subject roles.
|
||||
// This also means that each access has to be checked explicitly (same subject or represented subject).
|
||||
private RealSubjectEntity subject;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
|
||||
private HsOfficePersonRealEntity person; // TODO.impl: add RBAC-Support to ProfileEntity, see Story #
|
||||
|
||||
@Version
|
||||
private int version;
|
||||
|
||||
@Column
|
||||
private boolean active;
|
||||
|
||||
@Column
|
||||
private Integer globalUid;
|
||||
|
||||
@Column
|
||||
private Integer globalGid;
|
||||
|
||||
@Column(name = "password_hash")
|
||||
private String passwordHash;
|
||||
|
||||
@Column
|
||||
private List<String> totpSecrets;
|
||||
|
||||
@Column
|
||||
private String phonePassword;
|
||||
|
||||
@Column
|
||||
private String emailAddress;
|
||||
|
||||
@Column
|
||||
private String smsNumber;
|
||||
|
||||
@OneToMany(fetch = FetchType.EAGER, cascade = { MERGE, REFRESH })
|
||||
@JoinTable(
|
||||
name = "scope_mapping", schema = "hs_accounts",
|
||||
joinColumns = @JoinColumn(name = "profile_uuid", referencedColumnName = "uuid"),
|
||||
inverseJoinColumns = @JoinColumn(name = "scope_uuid", referencedColumnName = "uuid")
|
||||
)
|
||||
private Set<HsProfileScopeRealEntity> scopes;
|
||||
|
||||
public Set<HsProfileScopeRealEntity> getScopes() {
|
||||
if ( scopes == null ) {
|
||||
scopes = new HashSet<>();
|
||||
}
|
||||
return scopes;
|
||||
}
|
||||
|
||||
public void setSubject(final RealSubjectEntity subject) {
|
||||
this.uuid = subject.getUuid();
|
||||
this.subject = subject;
|
||||
}
|
||||
|
||||
public void setPassword(final String password) {
|
||||
setPasswordHash(
|
||||
HashGenerator.fromEnv("ACCOUNT_PROFILE_PASSWORD_HASH_ALGORITHM", "{SSHA}")
|
||||
.withRandomSalt().hash(password));
|
||||
}
|
||||
|
||||
public void setPasswordHash(final String passwordHash) {
|
||||
if (passwordHash != null) {
|
||||
validatePasswordHash(passwordHash);
|
||||
}
|
||||
this.passwordHash = passwordHash;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toShortString() {
|
||||
return active + ":" + emailAddress + ":" + globalUid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return stringify.apply(this);
|
||||
}
|
||||
|
||||
private static void validatePasswordHash(final String passwordHash) {
|
||||
|
||||
if (!LdapSshaHash.isValid(passwordHash) && !LdapArgon2Hash.isValid(passwordHash)) {
|
||||
throw new ValidationException("passwordHash must be SSHA or ARGON2 hash valid for LDAP");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package net.hostsharing.hsadminng.hs.accounts;
|
||||
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfilePatchResource;
|
||||
import net.hostsharing.hsadminng.mapper.EntityPatcher;
|
||||
import net.hostsharing.hsadminng.mapper.OptionalFromJson;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public class HsProfileEntityPatcher implements EntityPatcher<ProfilePatchResource> {
|
||||
|
||||
private ScopeResourceToEntityMapper scopeMapper;
|
||||
private final HsProfileEntity entity;
|
||||
|
||||
public HsProfileEntityPatcher(final ScopeResourceToEntityMapper scopeMapper, final HsProfileEntity entity) {
|
||||
this.scopeMapper = scopeMapper;
|
||||
this.entity = entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply(final ProfilePatchResource resource) {
|
||||
Optional.ofNullable(resource.getActive())
|
||||
.ifPresent(entity::setActive);
|
||||
OptionalFromJson.of(resource.getEmailAddress())
|
||||
.ifPresent(entity::setEmailAddress);
|
||||
Optional.ofNullable(resource.getTotpSecrets())
|
||||
.ifPresent(entity::setTotpSecrets);
|
||||
OptionalFromJson.of(resource.getSmsNumber())
|
||||
.ifPresent(entity::setSmsNumber);
|
||||
Optional.ofNullable(resource.getPassword())
|
||||
.ifPresent(entity::setPassword);
|
||||
OptionalFromJson.of(resource.getPhonePassword())
|
||||
.ifPresent(entity::setPhonePassword);
|
||||
if (resource.getScopes() != null) {
|
||||
scopeMapper.syncProfileScopeEntities(resource.getScopes(), entity.getScopes());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package net.hostsharing.hsadminng.hs.accounts;
|
||||
|
||||
import io.micrometer.core.annotation.Timed;
|
||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface HsProfileRepository extends Repository<HsProfileEntity, UUID> {
|
||||
|
||||
@Timed("app.login.profile.repo.findByUuid")
|
||||
Optional<HsProfileEntity> findByUuid(final UUID uuid);
|
||||
|
||||
@Timed("app.login.profile.repo.findByPerson")
|
||||
List<HsProfileEntity> findByPerson(final HsOfficePerson<?> personUuid);
|
||||
|
||||
@Timed("app.login.profile.repo.findByCurrentSubject")
|
||||
@Query(nativeQuery = true, value = """
|
||||
WITH RECURSIVE
|
||||
same_person AS (
|
||||
SELECT own_profile.person_uuid
|
||||
FROM hs_accounts.profile own_profile
|
||||
WHERE own_profile.uuid = rbac.currentSubjectUuid()
|
||||
),
|
||||
represented_persons AS (
|
||||
SELECT relation.anchorUuid person_uuid
|
||||
FROM hs_office.relation relation
|
||||
WHERE relation.type = 'REPRESENTATIVE'
|
||||
AND relation.holderUuid IN (SELECT person_uuid FROM same_person)
|
||||
)
|
||||
SELECT DISTINCT profile.*
|
||||
FROM hs_accounts.profile profile
|
||||
WHERE profile.person_uuid IN (SELECT person_uuid FROM same_person)
|
||||
OR profile.person_uuid IN (SELECT person_uuid FROM represented_persons)
|
||||
""")
|
||||
List<HsProfileEntity> findByCurrentSubject();
|
||||
|
||||
@Timed("app.login.profile.repo.save")
|
||||
HsProfileEntity save(final HsProfileEntity entity);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package net.hostsharing.hsadminng.hs.accounts;
|
||||
|
||||
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;
|
||||
import static net.hostsharing.hsadminng.repr.Symbol.symbol;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
@AllArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
@SuperBuilder(builderMethodName = "baseBuilder", toBuilder = true)
|
||||
@MappedSuperclass
|
||||
public abstract class HsProfileScope implements Stringifyable, BaseEntity<HsProfileScope> {
|
||||
|
||||
private static Stringify<HsProfileScope> stringify = stringify(HsProfileScope.class, "scope")
|
||||
.withProp(HsProfileScope::getType)
|
||||
.withProp(HsProfileScope::getQualifier)
|
||||
.withProp(
|
||||
HsProfileScope::isOnlyForNaturalPersons,
|
||||
value -> value ? symbol("NP-ONLY") : null)
|
||||
.withProp(
|
||||
HsProfileScope::isPublicAccess,
|
||||
value -> value ? symbol("PUBLIC") : symbol("INTERNAL"))
|
||||
.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;
|
||||
|
||||
@Column(name = "only_for_natural_persons")
|
||||
private boolean onlyForNaturalPersons;
|
||||
|
||||
@Column(name = "public_access")
|
||||
private boolean publicAccess;
|
||||
|
||||
public boolean isHsadminScope() {
|
||||
return "HSADMIN".equals(type);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toShortString() {
|
||||
return type + (qualifier != null ? ":" + qualifier : "");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return stringify.apply(this);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package net.hostsharing.hsadminng.hs.accounts;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.micrometer.core.annotation.Timed;
|
||||
import lombok.val;
|
||||
import net.hostsharing.hsadminng.config.NoSecurityRequirement;
|
||||
import net.hostsharing.hsadminng.rbac.context.Context;
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.api.ScopesApi;
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ScopeResource;
|
||||
import net.hostsharing.hsadminng.mapper.StrictMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@NoSecurityRequirement
|
||||
public class HsProfileScopeController implements ScopesApi {
|
||||
|
||||
@Autowired
|
||||
private Context context;
|
||||
|
||||
@Autowired
|
||||
private StrictMapper mapper;
|
||||
|
||||
@Autowired
|
||||
private HsProfileScopeRbacRepository scopeRepo;
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
@Timed("app.accounts.scopes.getListOfScopes")
|
||||
public ResponseEntity<List<ScopeResource>> getListOfScopes(final String assumedRoles) {
|
||||
if (SecurityContextHolder.getContext().getAuthentication().isAuthenticated()) {
|
||||
context.assumeRoles(assumedRoles);
|
||||
}
|
||||
val isGlobalAdmin = context.isGlobalAdmin();
|
||||
final var scopes = scopeRepo.findAll().stream().filter(
|
||||
scope -> scope.isPublicAccess() || isGlobalAdmin
|
||||
).toList();
|
||||
final var result = mapper.mapList(scopes, ScopeResource.class);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package net.hostsharing.hsadminng.hs.accounts;
|
||||
|
||||
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_accounts", name = "scope") // 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 HsProfileScopeRbacEntity extends HsProfileScope {
|
||||
|
||||
// TODO_impl: RBAC rules for _rv do not yet work properly (remove the X)
|
||||
public static RbacSpec rbacX() {
|
||||
return rbacViewFor("profileScope", HsProfileScopeRbacEntity.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-accounts/9513-hs-profile-rbac");
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package net.hostsharing.hsadminng.hs.accounts;
|
||||
|
||||
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 HsProfileScopeRbacRepository extends Repository<HsProfileScopeRbacEntity, UUID> {
|
||||
|
||||
@Timed("app.accounts.scope.repo.findAll.rbac")
|
||||
List<HsProfileScopeRbacEntity> findAll();
|
||||
|
||||
@Timed("app.accounts.scope.repo.findByUuid.rbac")
|
||||
Optional<HsProfileScopeRbacEntity> findByUuid(final UUID id);
|
||||
|
||||
@Timed("app.accounts.scope.repo.findByTypeAndQualifier.rbac")
|
||||
Optional<HsProfileScopeRbacEntity> findByTypeAndQualifier(@NotNull String contextType, @NotNull String qualifier);
|
||||
|
||||
@Timed("app.accounts.scope.repo.save.rbac")
|
||||
HsProfileScopeRbacEntity save(final HsProfileScopeRbacEntity entity);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package net.hostsharing.hsadminng.hs.accounts;
|
||||
|
||||
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_accounts", name = "scope")
|
||||
@SuperBuilder(toBuilder = true)
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AttributeOverrides({
|
||||
@AttributeOverride(name = "uuid", column = @Column(name = "uuid"))
|
||||
})
|
||||
public class HsProfileScopeRealEntity extends HsProfileScope {
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package net.hostsharing.hsadminng.hs.accounts;
|
||||
|
||||
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 HsProfileScopeRealRepository extends Repository<HsProfileScopeRealEntity, UUID> {
|
||||
|
||||
@Timed("app.account.scope.repo.findAll.real")
|
||||
List<HsProfileScopeRealEntity> findAll();
|
||||
|
||||
@Timed("app.account.scope.repo.findByUuid.real")
|
||||
Optional<HsProfileScopeRealEntity> findByUuid(final UUID id);
|
||||
|
||||
@Timed("app.account.scope.repo.findByTypeAndQualifier.real")
|
||||
Optional<HsProfileScopeRealEntity> findByTypeAndQualifier(@NotNull String type, @NotNull String qualifier);
|
||||
|
||||
@Timed("app.account.scope.repo.save.real")
|
||||
HsProfileScopeRealEntity save(final HsProfileScopeRealEntity entity);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package net.hostsharing.hsadminng.hs.accounts;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.EntityNotFoundException;
|
||||
|
||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ScopeResource;
|
||||
import net.hostsharing.hsadminng.config.MessageTranslator;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ScopeResourceToEntityMapper {
|
||||
|
||||
private final EntityManager em;
|
||||
private final MessageTranslator messageTranslator;
|
||||
|
||||
@Autowired
|
||||
public ScopeResourceToEntityMapper(final EntityManager em, final MessageTranslator messageTranslator) {
|
||||
this.em = em;
|
||||
this.messageTranslator = messageTranslator;
|
||||
}
|
||||
|
||||
public Set<HsProfileScopeRealEntity> mapProfileToScopeEntities(
|
||||
final List<ScopeResource> resources
|
||||
) {
|
||||
final var entities = new HashSet<HsProfileScopeRealEntity>();
|
||||
syncProfileScopeEntities(resources, entities);
|
||||
return entities;
|
||||
}
|
||||
|
||||
public void syncProfileScopeEntities(
|
||||
final List<ScopeResource> resources,
|
||||
final Set<HsProfileScopeRealEntity> entities
|
||||
) {
|
||||
final var resourceUuids = resources.stream()
|
||||
.map(ScopeResource::getUuid)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
final var entityUuids = entities.stream()
|
||||
.map(HsProfileScopeRealEntity::getUuid)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
entities.removeIf(e -> !resourceUuids.contains(e.getUuid()));
|
||||
|
||||
for (final var resource : resources) {
|
||||
if (!entityUuids.contains(resource.getUuid())) {
|
||||
final var existingScopeEntity = em.find(HsProfileScopeRealEntity.class, resource.getUuid());
|
||||
if (existingScopeEntity == null) {
|
||||
throw new EntityNotFoundException(
|
||||
messageTranslator.translate(
|
||||
"general.{0}-{1}-not-found-or-not-accessible",
|
||||
"profile uuid", resource.getUuid()));
|
||||
}
|
||||
if ((resource.getType() != null && !existingScopeEntity.getType().equals(resource.getType())) ||
|
||||
(resource.getQualifier() != null && !existingScopeEntity.getQualifier().equals(resource.getQualifier()))) {
|
||||
throw new EntityNotFoundException(
|
||||
messageTranslator.translate(
|
||||
"profile.existing-profile-scope-{0}-does-not-match-given-resource-{1}",
|
||||
existingScopeEntity, resource));
|
||||
}
|
||||
entities.add(existingScopeEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-6
@@ -2,12 +2,14 @@ package net.hostsharing.hsadminng.hs.office.person;
|
||||
|
||||
import io.micrometer.core.annotation.Timed;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import net.hostsharing.hsadminng.mapper.StrictMapper;
|
||||
import net.hostsharing.hsadminng.rbac.context.Context;
|
||||
import lombok.val;
|
||||
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficePersonsApi;
|
||||
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonInsertResource;
|
||||
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonPatchResource;
|
||||
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonResource;
|
||||
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonTypeResource;
|
||||
import net.hostsharing.hsadminng.mapper.StrictMapper;
|
||||
import net.hostsharing.hsadminng.rbac.context.Context;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
@@ -38,14 +40,23 @@ public class HsOfficePersonController implements HsOfficePersonsApi {
|
||||
public ResponseEntity<List<HsOfficePersonResource>> getListOfPersons(
|
||||
final String assumedRoles,
|
||||
final String name,
|
||||
final HsOfficePersonTypeResource type,
|
||||
final UUID representedByPersonUuid) {
|
||||
context.assumeRoles(assumedRoles);
|
||||
|
||||
final var entities = representedByPersonUuid != null
|
||||
? personRepo.findPersonsRepresentedByPersonWithUuid(representedByPersonUuid)
|
||||
: personRepo.findPersonByOptionalNameLike(name);
|
||||
val personType = type != null ? HsOfficePersonType.valueOf(type.name()) : null;
|
||||
// @formatter:off
|
||||
val entities = (
|
||||
representedByPersonUuid != null
|
||||
? personRepo.findPersonsRepresentedByPersonWithUuid(representedByPersonUuid)
|
||||
: personRepo.findPersonByOptionalNameLike(name)
|
||||
).stream()
|
||||
// TODO.perf: this could be moved into the queries to improve the performance a bit
|
||||
.filter(p -> personType == null || p.getPersonType() == personType)
|
||||
.toList();
|
||||
// @formatter:on
|
||||
|
||||
final var resources = mapper.mapList(entities, HsOfficePersonResource.class);
|
||||
val resources = mapper.mapList(entities, HsOfficePersonResource.class);
|
||||
return ResponseEntity.ok(resources);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package net.hostsharing.hsadminng.rbac.grant;
|
||||
|
||||
import net.hostsharing.hsadminng.rbac.role.RbacRoleEntity;
|
||||
import net.hostsharing.hsadminng.rbac.subject.Subject;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class RbacGrantService {
|
||||
|
||||
@Autowired
|
||||
private RbacGrantRepository rbacGrantRepo;
|
||||
|
||||
public class RbacRoleGranter{
|
||||
private final RbacRoleEntity role;
|
||||
|
||||
public RbacRoleGranter(final RbacRoleEntity role) {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public void to(final Subject subject) {
|
||||
rbacGrantRepo.save(RbacGrantEntity.builder()
|
||||
.grantedRoleUuid(role.getUuid())
|
||||
.granteeSubjectUuid(subject.getUuid())
|
||||
.assumed(true)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
public RbacRoleGranter grant(final RbacRoleEntity role) {
|
||||
return new RbacRoleGranter(role);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -24,6 +24,19 @@ public interface RbacRoleRepository extends Repository<RbacRoleEntity, UUID> {
|
||||
@Timed("app.rbac.roles.repo.findByRoleName")
|
||||
RbacRoleEntity findByRoleName(String roleName);
|
||||
|
||||
@Timed("app.rbac.roles.repo.findByObjectUuidAndRoleType")
|
||||
@Query(value = """
|
||||
SELECT rev.*, rev.objectTable||'#'||rev.objectIdName||':'||rev.roleType AS roleName
|
||||
FROM rbac.role_rv rev
|
||||
WHERE rev.objectuuid = :objectUuid
|
||||
AND rev.roletype = cast(:roleType as rbac.roletype)
|
||||
""", nativeQuery = true)
|
||||
RbacRoleEntity findByObjectUuidAndRoleType(UUID objectUuid, String roleType);
|
||||
|
||||
default RbacRoleEntity findByObjectUuidAndRoleType(UUID objectUuid, RbacRoleType roleType) {
|
||||
return findByObjectUuidAndRoleType(objectUuid, roleType.name());
|
||||
}
|
||||
|
||||
@Timed("app.rbac.roles.repo.fetchAssumedRoles")
|
||||
@Query(value = """
|
||||
SELECT rev.*, rev.objectTable||'#'||rev.objectIdName||':'||rev.roleType AS roleName
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package net.hostsharing.hsadminng.rbac.role;
|
||||
|
||||
import lombok.val;
|
||||
import net.hostsharing.hsadminng.persistence.BaseEntity;
|
||||
import net.hostsharing.hsadminng.repr.Stringifyable;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
|
||||
/// Just a APIs to programmatically handle RBAC roles.
|
||||
@Service
|
||||
public class RbacRoleService{
|
||||
|
||||
@Autowired
|
||||
private RbacRoleRepository rbacRoleRepo;
|
||||
|
||||
public RbacRoleEntity rbacRole(final BaseEntity<?> rbacEntity, final RbacRoleType roleType) {
|
||||
val personAdminRole = rbacRoleRepo.findByObjectUuidAndRoleType(rbacEntity.getUuid(), roleType);
|
||||
if (personAdminRole == null) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"no ADMIN role not found for %s %s".formatted(
|
||||
Stringifyable.toShortString(rbacEntity),
|
||||
rbacEntity.getUuid()
|
||||
));
|
||||
}
|
||||
return personAdminRole;
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ public final class Stringify<B> {
|
||||
private class Property<B, V> {
|
||||
String name;
|
||||
Function<B, V> getter;
|
||||
Function<V, ?> mapper; // FIXME: better generics?
|
||||
Function<V, ?> mapper; // TODO.impl: better generics?
|
||||
|
||||
Property(String name, Function<B, V> getter) {
|
||||
this(name, getter, v -> v);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
package net.hostsharing.hsadminng.repr;
|
||||
|
||||
import net.hostsharing.hsadminng.persistence.BaseEntity;
|
||||
|
||||
public interface Stringifyable {
|
||||
|
||||
static String toShortString(final BaseEntity<?> entity) {
|
||||
return entity instanceof Stringifyable stringifyableEntity
|
||||
? stringifyableEntity.toShortString()
|
||||
: entity.getUuid().toString();
|
||||
}
|
||||
|
||||
String toShortString();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
|
||||
components:
|
||||
|
||||
schemas:
|
||||
|
||||
CurrentLoginUser:
|
||||
type: object
|
||||
properties:
|
||||
subject:
|
||||
$ref: '../rbac/rbac-subject-schemas.yaml#/components/schemas/RbacSubject'
|
||||
person:
|
||||
$ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson'
|
||||
globalAdmin:
|
||||
type: boolean
|
||||
|
||||
Account:
|
||||
type: object
|
||||
properties:
|
||||
uuid:
|
||||
type: string
|
||||
format: uuid
|
||||
person:
|
||||
$ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson'
|
||||
subjectName:
|
||||
type: string
|
||||
description: "
|
||||
Two parts separated by a single '-'.
|
||||
The first part is the Keycloak realm name of 3-9 chars length.
|
||||
The second part a account name local to that realm, which is 3-32 chars in length.
|
||||
Each part starts with a lowercase letter and may contain lowercase letters, digits, '.' or '_',
|
||||
but '.'/'_' must be followed by a letter or digit.
|
||||
"
|
||||
pattern: '^[a-z](?:[a-z0-9]|[._](?=[a-z0-9])){2,8}-[a-z](?:[a-z0-9]|[._](?=[a-z0-9])){2,31}$'
|
||||
globalUid:
|
||||
type: number
|
||||
globalGid:
|
||||
type: number
|
||||
required:
|
||||
- uuid
|
||||
- subjectName
|
||||
- person
|
||||
- globalUid
|
||||
- globalGid
|
||||
additionalProperties: false
|
||||
|
||||
AccountInsert:
|
||||
type: object
|
||||
properties:
|
||||
person.uuid:
|
||||
type: string
|
||||
format: uuid
|
||||
person:
|
||||
$ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonInsert'
|
||||
subjectName:
|
||||
type: string
|
||||
description: "
|
||||
Two parts separated by a single '-'.
|
||||
The first part is the Keycloak realm name of 3-9 chars length.
|
||||
The second part a account name local to that realm, which is 3-32 chars in length.
|
||||
Each part starts with a lowercase letter and may contain lowercase letters, digits, '.' or '_',
|
||||
but '.'/'_' must be followed by a letter or digit.
|
||||
"
|
||||
pattern: '^[a-z](?:[a-z0-9]|[._](?=[a-z0-9])){2,8}-[a-z](?:[a-z0-9]|[._](?=[a-z0-9])){2,31}$'
|
||||
globalUid:
|
||||
type: number
|
||||
globalGid:
|
||||
type: number
|
||||
required:
|
||||
- subjectName
|
||||
- globalUid
|
||||
- globalGid
|
||||
# soon we might need to be able to use this:
|
||||
# https://community.smartbear.com/discussions/swaggerostools/defining-conditional-attributes-in-openapi/222410
|
||||
# For now we just describe the conditionally required properties:
|
||||
description:
|
||||
Either `person.uuid` or `person` need to be given.
|
||||
additionalProperties: false
|
||||
+11
-11
@@ -1,9 +1,9 @@
|
||||
get:
|
||||
summary: Returns a list of all profile.
|
||||
description: Returns the list of all profile which are visible to the current subject or any of it's assumed roles.
|
||||
summary: Returns a list of all account.
|
||||
description: Returns the list of all account which are visible to the current subject or any of it's assumed roles.
|
||||
tags:
|
||||
- profile
|
||||
operationId: getListOfProfile
|
||||
- account
|
||||
operationId: getListIfAccount
|
||||
parameters:
|
||||
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
|
||||
- name: personUuid
|
||||
@@ -12,7 +12,7 @@ get:
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: The UUID of the person, whose profile are to be fetched. Or null, if all profile of the login-use should be fetched.
|
||||
description: The UUID of the person, whose account are to be fetched. Or null, if all account of the login-use should be fetched.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
@@ -21,31 +21,31 @@ get:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: 'profile-schemas.yaml#/components/schemas/Profile'
|
||||
$ref: 'account-schemas.yaml#/components/schemas/Account'
|
||||
"401":
|
||||
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
||||
"403":
|
||||
$ref: 'error-responses.yaml#/components/responses/Forbidden'
|
||||
|
||||
post:
|
||||
summary: Adds a new profile.
|
||||
summary: Adds a new account.
|
||||
tags:
|
||||
- profile
|
||||
operationId: postNewProfile
|
||||
- account
|
||||
operationId: postNewAccount
|
||||
requestBody:
|
||||
description: A JSON object describing the new credential.
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: 'profile-schemas.yaml#/components/schemas/ProfileInsert'
|
||||
$ref: 'account-schemas.yaml#/components/schemas/AccountInsert'
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
content:
|
||||
'application/json':
|
||||
schema:
|
||||
$ref: 'profile-schemas.yaml#/components/schemas/Profile'
|
||||
$ref: 'account-schemas.yaml#/components/schemas/Account'
|
||||
"401":
|
||||
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
||||
"403":
|
||||
@@ -0,0 +1,48 @@
|
||||
get:
|
||||
tags:
|
||||
- account
|
||||
description: 'Fetch a single account its uuid, if visible for the current subject.'
|
||||
operationId: getSingleAccountByUuid
|
||||
parameters:
|
||||
- name: accountUuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: UUID of the account to fetch.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
'application/json':
|
||||
schema:
|
||||
$ref: 'account-schemas.yaml#/components/schemas/Account'
|
||||
|
||||
"401":
|
||||
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
||||
"403":
|
||||
$ref: 'error-responses.yaml#/components/responses/Forbidden'
|
||||
|
||||
delete:
|
||||
tags:
|
||||
- account
|
||||
description: 'Delete a single account identified by its uuid, if permitted for the current subject.'
|
||||
operationId: deleteAccountByUuid
|
||||
parameters:
|
||||
- name: accountUuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: UUID of the account 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'
|
||||
@@ -13,5 +13,5 @@ map:
|
||||
- type: string:uuid => java.util.UUID
|
||||
|
||||
paths:
|
||||
/api/hs/accounts/profiles/{profileUuid}:
|
||||
/api/hs/accounts/accounts/{accountUuid}:
|
||||
null: org.openapitools.jackson.nullable.JsonNullable
|
||||
|
||||
@@ -13,17 +13,11 @@ paths:
|
||||
/api/hs/accounts/current:
|
||||
$ref: "current.yaml"
|
||||
|
||||
# Scopes
|
||||
# Account
|
||||
|
||||
/api/hs/accounts/scopes:
|
||||
$ref: "scopes.yaml"
|
||||
/api/hs/accounts/accounts/{accountUuid}:
|
||||
$ref: "accout-with-uuid.yaml"
|
||||
|
||||
|
||||
# Profile
|
||||
|
||||
/api/hs/accounts/profiles/{profileUuid}:
|
||||
$ref: "profile-with-uuid.yaml"
|
||||
|
||||
/api/hs/accounts/profiles:
|
||||
$ref: "profiles.yaml"
|
||||
/api/hs/accounts/accounts:
|
||||
$ref: "accounts.yaml"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ get:
|
||||
summary: Currently logged in user data.
|
||||
description: Returns information about the currently logged in user.
|
||||
tags:
|
||||
- profile
|
||||
- account
|
||||
operationId: getCurrentLoginUser
|
||||
responses:
|
||||
"200":
|
||||
@@ -10,7 +10,7 @@ get:
|
||||
content:
|
||||
'application/json':
|
||||
schema:
|
||||
$ref: 'profile-schemas.yaml#/components/schemas/CurrentLoginUser'
|
||||
$ref: 'account-schemas.yaml#/components/schemas/CurrentLoginUser'
|
||||
"401":
|
||||
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
||||
"403":
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
|
||||
components:
|
||||
|
||||
schemas:
|
||||
|
||||
CurrentLoginUser:
|
||||
type: object
|
||||
properties:
|
||||
subject:
|
||||
$ref: '../rbac/rbac-subject-schemas.yaml#/components/schemas/RbacSubject'
|
||||
person:
|
||||
$ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson'
|
||||
globalAdmin:
|
||||
type: boolean
|
||||
|
||||
Profile:
|
||||
type: object
|
||||
properties:
|
||||
uuid:
|
||||
type: string
|
||||
format: uuid
|
||||
person:
|
||||
$ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson'
|
||||
nickname:
|
||||
type: string
|
||||
pattern: '^[a-z][a-z0-9]{1,8}-[a-z0-9]{1,10}$' # TODO.spec: pattern for login nickname
|
||||
emailAddress:
|
||||
type: string
|
||||
smsNumber:
|
||||
type: string
|
||||
totpSecrets:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
phonePassword:
|
||||
type: string
|
||||
active:
|
||||
type: boolean
|
||||
globalUid:
|
||||
type: number
|
||||
globalGid:
|
||||
type: number
|
||||
scopes:
|
||||
type: array
|
||||
items:
|
||||
$ref: 'scope-schemas.yaml#/components/schemas/Scope'
|
||||
required:
|
||||
- uuid
|
||||
- active
|
||||
- scopes
|
||||
additionalProperties: false
|
||||
|
||||
ProfilePatch:
|
||||
type: object
|
||||
properties:
|
||||
emailAddress:
|
||||
type: string
|
||||
nullable: true
|
||||
smsNumber:
|
||||
type: string
|
||||
nullable: true
|
||||
totpSecrets:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
minLength: 8
|
||||
description: plaintext password or valid hash
|
||||
phonePassword:
|
||||
type: string
|
||||
nullable: true
|
||||
active:
|
||||
type: boolean
|
||||
scopes:
|
||||
type: array
|
||||
items:
|
||||
$ref: 'scope-schemas.yaml#/components/schemas/Scope'
|
||||
additionalProperties: false
|
||||
|
||||
ProfileInsert:
|
||||
type: object
|
||||
properties:
|
||||
person.uuid:
|
||||
type: string
|
||||
format: uuid
|
||||
person:
|
||||
$ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonInsert'
|
||||
nickname:
|
||||
type: string
|
||||
pattern: '^[a-z][a-z0-9]{1,8}-[a-z0-9]{1,10}$' # TODO.spec: pattern for login nickname
|
||||
emailAddress:
|
||||
type: string
|
||||
smsNumber:
|
||||
type: string
|
||||
active:
|
||||
type: boolean
|
||||
globalUid:
|
||||
type: number
|
||||
globalGid:
|
||||
type: number
|
||||
password:
|
||||
type: string
|
||||
minLength: 8
|
||||
description: plaintext password or valid hash
|
||||
phonePassword:
|
||||
type: string
|
||||
totpSecrets:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
scopes:
|
||||
type: array
|
||||
items:
|
||||
$ref: 'scope-schemas.yaml#/components/schemas/Scope'
|
||||
required:
|
||||
- nickname
|
||||
- active
|
||||
# soon we might need to be able to use this:
|
||||
# https://community.smartbear.com/discussions/swaggerostools/defining-conditional-attributes-in-openapi/222410
|
||||
# For now we just describe the conditionally required properties:
|
||||
description:
|
||||
Either `person.uuid` or `person` need to be given.
|
||||
additionalProperties: false
|
||||
@@ -1,77 +0,0 @@
|
||||
get:
|
||||
tags:
|
||||
- profile
|
||||
description: 'Fetch a single profile its uuid, if visible for the current subject.'
|
||||
operationId: getSingleProfileByUuid
|
||||
parameters:
|
||||
- name: profileUuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: UUID of the profile to fetch.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
'application/json':
|
||||
schema:
|
||||
$ref: 'profile-schemas.yaml#/components/schemas/Profile'
|
||||
|
||||
"401":
|
||||
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
||||
"403":
|
||||
$ref: 'error-responses.yaml#/components/responses/Forbidden'
|
||||
|
||||
patch:
|
||||
tags:
|
||||
- profile
|
||||
description: 'Updates a single profile identified by its uuid, if permitted for the current subject.'
|
||||
operationId: patchProfile
|
||||
parameters:
|
||||
- name: profileUuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
content:
|
||||
'application/json':
|
||||
schema:
|
||||
$ref: 'profile-schemas.yaml#/components/schemas/ProfilePatch'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
'application/json':
|
||||
schema:
|
||||
$ref: 'profile-schemas.yaml#/components/schemas/Profile'
|
||||
"401":
|
||||
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
||||
"403":
|
||||
$ref: 'error-responses.yaml#/components/responses/Forbidden'
|
||||
|
||||
delete:
|
||||
tags:
|
||||
- profile
|
||||
description: 'Delete a single profile identified by its uuid, if permitted for the current subject.'
|
||||
operationId: deleteProfileByUuid
|
||||
parameters:
|
||||
- name: profileUuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: UUID of the profile 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'
|
||||
@@ -1,23 +0,0 @@
|
||||
|
||||
components:
|
||||
|
||||
schemas:
|
||||
|
||||
Scope:
|
||||
type: object
|
||||
properties:
|
||||
uuid:
|
||||
type: string
|
||||
format: uuid
|
||||
type:
|
||||
type: string
|
||||
maxLength: 16
|
||||
qualifier:
|
||||
type: string
|
||||
maxLength: 80
|
||||
onlyForNaturalPersons:
|
||||
type: boolean
|
||||
publicAccess:
|
||||
type: boolean
|
||||
required:
|
||||
- uuid
|
||||
@@ -1,21 +0,0 @@
|
||||
get:
|
||||
summary: Returns a list of all accessible scopes.
|
||||
description: Returns the list of all scopes which are visible to the current subject or any of it's assumed roles.
|
||||
tags:
|
||||
- scopes
|
||||
operationId: getListOfScopes
|
||||
parameters:
|
||||
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
'application/json':
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: 'scope-schemas.yaml#/components/schemas/Scope'
|
||||
"401":
|
||||
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
||||
"403":
|
||||
$ref: 'error-responses.yaml#/components/responses/Forbidden'
|
||||
@@ -12,6 +12,12 @@ get:
|
||||
schema:
|
||||
type: string
|
||||
description: Prefix of caption to filter the results.
|
||||
- name: type
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
$ref: 'hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonType'
|
||||
description: Filter the results by person type.
|
||||
- name: representedByPersonUuid
|
||||
in: query
|
||||
required: false
|
||||
|
||||
@@ -5,6 +5,12 @@ management:
|
||||
server:
|
||||
port: 8081
|
||||
address: 127.0.0.1
|
||||
info:
|
||||
build:
|
||||
enabled: true
|
||||
git:
|
||||
enabled: true
|
||||
mode: full
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
@@ -96,5 +102,3 @@ spring:
|
||||
jwt:
|
||||
issuer-uri: "http://localhost:${server.port}/fake-jwt"
|
||||
jwk-set-uri: "http://localhost:${server.port}/fake-jwt/.well-known/jwks.json"
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs-profile-SCHEMA endDelimiter:--//
|
||||
--changeset michael.hoennig:hs-accounts-SCHEMA endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
CREATE SCHEMA hs_accounts;
|
||||
--//
|
||||
|
||||
@@ -2,76 +2,18 @@
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs-profile-PROFILE-TABLE endDelimiter:--//
|
||||
--changeset michael.hoennig:hs-accounts-ACCOUNT-TABLE endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
create table hs_accounts.profile
|
||||
create table hs_accounts.account
|
||||
(
|
||||
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
|
||||
|
||||
password_hash text,
|
||||
totp_secrets text[],
|
||||
phone_password text,
|
||||
email_address text,
|
||||
sms_number text
|
||||
);
|
||||
--//
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs-profile-scope-SCOPE-TABLE endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
create table hs_accounts.scope
|
||||
(
|
||||
uuid uuid PRIMARY KEY,
|
||||
version int not null default 0,
|
||||
|
||||
type varchar(16),
|
||||
qualifier varchar(80),
|
||||
|
||||
only_for_natural_persons boolean default false,
|
||||
|
||||
public_access boolean default false,
|
||||
|
||||
unique (type, qualifier)
|
||||
);
|
||||
--//
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs-profile-SCOPE-IMMUTABLE-TRIGGER endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION hs_accounts.prevent_scope_update()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'Updates to hs_accounts.scope are not allowed.';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger to enforce immutability
|
||||
CREATE TRIGGER scope_immutable_trigger
|
||||
BEFORE UPDATE ON hs_accounts.scope
|
||||
FOR EACH ROW EXECUTE FUNCTION hs_accounts.prevent_scope_update();
|
||||
--//
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs_accounts-SCOPE-MAPPING endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
create table hs_accounts.scope_mapping
|
||||
(
|
||||
uuid uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
profile_uuid uuid references hs_accounts.profile(uuid) ON DELETE CASCADE,
|
||||
scope_uuid uuid references hs_accounts.scope(uuid) ON DELETE RESTRICT
|
||||
global_gid int unique -- w/o
|
||||
);
|
||||
--//
|
||||
|
||||
@@ -80,16 +22,12 @@ create table hs_accounts.scope_mapping
|
||||
--changeset michael.hoennig:hs-hs_accounts-JOURNALS endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
call base.create_journal('hs_accounts.scope_mapping');
|
||||
call base.create_journal('hs_accounts.scope');
|
||||
call base.create_journal('hs_accounts.profile');
|
||||
call base.create_journal('hs_accounts.account');
|
||||
--//
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs_accounts-HISTORICIZATION endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
call base.tx_create_historicization('hs_accounts.scope_mapping');
|
||||
call base.tx_create_historicization('hs_accounts.scope');
|
||||
call base.tx_create_historicization('hs_accounts.profile');
|
||||
call base.tx_create_historicization('hs_accounts.account');
|
||||
--//
|
||||
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
### rbac profileContext
|
||||
|
||||
This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually.
|
||||
|
||||
```mermaid
|
||||
%%{init:{'flowchart':{'htmlLabels':false}}}%%
|
||||
flowchart TB
|
||||
|
||||
subgraph profileContext["`**profileContext**`"]
|
||||
direction TB
|
||||
style profileContext fill:#dd4901,stroke:#274d6e,stroke-width:8px
|
||||
|
||||
subgraph profileContext:roles[ ]
|
||||
style profileContext:roles fill:#dd4901,stroke:white
|
||||
|
||||
role:profileContext:OWNER[[profileContext:OWNER]]
|
||||
role:profileContext:ADMIN[[profileContext:ADMIN]]
|
||||
role:profileContext:REFERRER[[profileContext:REFERRER]]
|
||||
end
|
||||
|
||||
subgraph profileContext:permissions[ ]
|
||||
style profileContext:permissions fill:#dd4901,stroke:white
|
||||
|
||||
perm:profileContext:INSERT{{profileContext:INSERT}}
|
||||
perm:profileContext:UPDATE{{profileContext:UPDATE}}
|
||||
perm:profileContext:DELETE{{profileContext:DELETE}}
|
||||
perm:profileContext:SELECT{{profileContext:SELECT}}
|
||||
end
|
||||
end
|
||||
|
||||
%% granting roles to roles
|
||||
role:profileContext:OWNER ==> role:profileContext:ADMIN
|
||||
role:profileContext:ADMIN ==> role:profileContext:REFERRER
|
||||
|
||||
%% granting permissions to roles
|
||||
role:rbac.global:ADMIN ==> perm:profileContext:INSERT
|
||||
role:rbac.global:ADMIN ==> perm:profileContext:UPDATE
|
||||
role:rbac.global:ADMIN ==> perm:profileContext:DELETE
|
||||
role:rbac.global:REFERRER ==> perm:profileContext:SELECT
|
||||
|
||||
```
|
||||
+6
-59
@@ -2,7 +2,7 @@
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs_accounts-profile-TEST-DATA context:!without-test-data endDelimiter:--//
|
||||
--changeset michael.hoennig:hs_accounts-account-TEST-DATA context:!without-test-data endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
do language plpgsql $$
|
||||
@@ -15,13 +15,6 @@ declare
|
||||
userDrewSubjectUuid uuid;
|
||||
personDrewUuid uuid;
|
||||
|
||||
|
||||
scope_HSADMIN_prod hs_accounts.scope;
|
||||
scope_SSH_internal hs_accounts.scope;
|
||||
scope_SSH_external hs_accounts.scope;
|
||||
scope_MATRIX_internal hs_accounts.scope;
|
||||
scope_MATRIX_external hs_accounts.scope;
|
||||
|
||||
begin
|
||||
call base.defineContext('creating booking-project test-data', null, 'superuser-alex@hostsharing.net', 'rbac.global#global:ADMIN');
|
||||
|
||||
@@ -32,57 +25,11 @@ begin
|
||||
userDrewSubjectUuid = (SELECT uuid FROM rbac.subject WHERE name='selfregistered-user-drew@hostsharing.org');
|
||||
personDrewUuid = (SELECT uuid FROM hs_office.person WHERE givenName='Drew');
|
||||
|
||||
-- Add test scopes
|
||||
INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES
|
||||
('11111111-1111-1111-1111-111111111111', 'HSADMIN', 'prod', true, true)
|
||||
RETURNING * INTO scope_HSADMIN_prod;
|
||||
INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES
|
||||
('22222222-2222-2222-2222-222222222222', 'SSH', 'internal', true, false)
|
||||
RETURNING * INTO scope_SSH_internal;
|
||||
INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES
|
||||
('33333333-3333-3333-3333-333333333333', 'SSH', 'external', false, true)
|
||||
RETURNING * INTO scope_SSH_external;
|
||||
INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES
|
||||
('44444444-4444-4444-4444-444444444444', 'MATRIX', 'internal', true, false)
|
||||
RETURNING * INTO scope_MATRIX_internal;
|
||||
INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES
|
||||
('55555555-5555-5555-5555-555555555555', 'MATRIX', 'external', true, true)
|
||||
RETURNING * INTO scope_MATRIX_external;
|
||||
INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES
|
||||
('66666666-6666-6666-6666-666666666666', 'MASTODON', 'external', false, true);
|
||||
INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES
|
||||
('77777777-7777-7777-7777-777777777777', 'BBB', 'external', false, true);
|
||||
|
||||
-- grant general access to public credential scopes
|
||||
-- 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_accounts.scope_REFERRER(context_SSH_internal), rbac.global_ADMIN());
|
||||
-- call rbac.grantRoleToRole(hs_accounts.scope_REFERRER(context_MATRIX_internal), rbac.global_ADMIN());
|
||||
|
||||
-- Add test profile (linking to assumed rbac.subject UUIDs)
|
||||
INSERT INTO hs_accounts.profile (uuid, version, person_uuid, active, global_uid, global_gid, totp_secrets, phone_password, email_address, sms_number) VALUES
|
||||
( superuserAlexSubjectUuid, 0, personAlexUuid, true, 1001, 1001, ARRAY['otp-secret-1a', 'otp-secret-1b'], 'phone-pw-1', 'alex@example.com', '111-222-3333'),
|
||||
( superuserFranSubjectUuid, 0, personFranUuid, true, 1002, 1002, ARRAY['otp-secret-2'], 'phone-pw-2', 'fran@example.com', '444-555-6666'),
|
||||
( userDrewSubjectUuid, 0, personDrewUuid, true, 1003, 1003, ARRAY['otp-secret-3'], 'phone-pw-3', 'drew@example.org', '999-888-7777');
|
||||
|
||||
-- Map profile to contexts
|
||||
INSERT INTO hs_accounts.scope_mapping (profile_uuid, scope_uuid) VALUES
|
||||
(superuserAlexSubjectUuid, scope_HSADMIN_prod.uuid),
|
||||
(superuserFranSubjectUuid, scope_HSADMIN_prod.uuid),
|
||||
(userDrewSubjectUuid, scope_HSADMIN_prod.uuid),
|
||||
(superuserAlexSubjectUuid, scope_SSH_internal.uuid),
|
||||
(superuserFranSubjectUuid, scope_SSH_internal.uuid),
|
||||
(userDrewSubjectUuid, scope_SSH_external.uuid),
|
||||
(superuserAlexSubjectUuid, scope_MATRIX_internal.uuid),
|
||||
(superuserFranSubjectUuid, scope_MATRIX_internal.uuid);
|
||||
-- Add test account (linking to assumed rbac.subject UUIDs)
|
||||
INSERT INTO hs_accounts.account (uuid, version, person_uuid, global_uid, global_gid) VALUES
|
||||
( superuserAlexSubjectUuid, 0, personAlexUuid, 1001, 1001),
|
||||
( superuserFranSubjectUuid, 0, personFranUuid, 1002, 1002),
|
||||
( userDrewSubjectUuid, 0, personDrewUuid, 1003, 1003);
|
||||
|
||||
end; $$;
|
||||
--//
|
||||
|
||||
@@ -10,12 +10,9 @@ general.{0}-{1}-not-found={0} "{1}" nicht gefunden
|
||||
general.{0}-{1}-not-found-or-not-accessible={0} "{1}" nicht gefunden oder nicht zugänglich
|
||||
general.but-is=ist aber
|
||||
|
||||
# profile validations
|
||||
profile.existing-profile-scope-{0}-does-not-match-given-resource-{1}=existierender Gültigkeitsbereich {0} passt nicht zum angegebenen {1}
|
||||
profile.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person=Zugriff verweigert: personUuid "{0}" wird von der eingeloggten Person nicht repräsentiert
|
||||
profile.access-denied-for-scopes-{0}=Zugriff auf Geltungsbereich verweigert: {0}
|
||||
profile.scope-requires-natural-person-{0}=Geltungsbereich verlangt eine natürliche Person: {0}
|
||||
profile.own-hsadmin-profile-must-not-be-removed=die eigenen hsadmin-Profile dürfen nicht entfernt werden
|
||||
# account validations
|
||||
account.access-denied-to-create-new-account-subject-{0}-is-not-a-global-admin=Zugriff verweigert: Neue Accounts können nur von globalen Admins angelegt werden, {0} ist kein solcher
|
||||
account.only-natural-persons-allowed-but-{0}-is-{1}=Nur natürliche Personen sind erlaubt, aber {0} ist {1}
|
||||
|
||||
# office.coop-shares
|
||||
office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=für transactionType={0}, muss shareCount positiv sein, ist aber {1}
|
||||
|
||||
@@ -10,12 +10,9 @@ general.{0}-{1}-not-found={0} "{1}" not found
|
||||
general.{0}-{1}-not-found-or-not-accessible={0} "{1}" not found or not accessible
|
||||
general.but-is=but is
|
||||
|
||||
# profile validations
|
||||
profile.existing-profile-scope-{0}-does-not-match-given-resource-{1}=existing {0} does not match given resource {1}
|
||||
profile.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person=access denied: personUuid "{0}" not represented by currently logged in person
|
||||
profile.access-denied-for-scopes-{0}=scope-access denied: {0}
|
||||
profile.scope-requires-natural-person-{0}=scope requires natural person: {0}
|
||||
profile.own-hsadmin-profile-must-not-be-removed=own hsadmin-profile must not be removed
|
||||
# account validations
|
||||
account.access-denied-to-create-new-account-subject-{0}-is-not-a-global-admin=Access denied: new accounts can only be created by global admins, {0} is not
|
||||
account.only-natural-persons-allowed-but-{0}-is-{1}=only natural persons allowed, but {0} is {1}
|
||||
|
||||
# office.coop-shares
|
||||
office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=for transactiontType {0} shareCount must be positive but is {1}
|
||||
|
||||
@@ -11,11 +11,8 @@ general.{0}-{1}-not-found-or-not-accessible={0} "{1}" non trouvé ou non accessi
|
||||
general.but-is=mais c'est
|
||||
|
||||
# profile validations
|
||||
profile.existing-profile-scope-{0}-does-not-match-given-resource-{1}={0} existant ne correspond pas à la ressource donnée {1}
|
||||
profile.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person=accès refusé : personUuid "{0}" non représenté par la personne actuellement connectée
|
||||
profile.access-denied-for-scopes-{0}=accès au domaine d'application refusé : {0}
|
||||
profile.scope-requires-natural-person-{0}=le domaine d'application requiert une personne physique : {0}
|
||||
profile.own-hsadmin-profile-must-not-be-removed=suppression des identifiants hsadmin propres interdite
|
||||
account.access-denied-to-create-new-account-subject-{0}-is-not-a-global-admin=Accès refusé : seuls les administrateurs globaux peuvent créer de nouveaux comptes, {0} ne l’est pas
|
||||
account.only-natural-persons-allowed-but-{0}-is-{1}=seulement personnes physiques sont accepté, mais {0} est {1}
|
||||
|
||||
# office.coop-shares
|
||||
office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=pour le type de transaction {0}, shareCount doit être positif mais est {1}
|
||||
|
||||
Reference in New Issue
Block a user