RbacSubjectController.java

package net.hostsharing.hsadminng.rbac.subject;

import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ValidationException;
import jakarta.validation.constraints.Pattern;
import lombok.val;
import net.hostsharing.hsadminng.config.ApiKey;
import net.hostsharing.hsadminng.config.MessageTranslator;
import net.hostsharing.hsadminng.rbac.context.Context;
import net.hostsharing.hsadminng.errors.ForbiddenException;
import net.hostsharing.hsadminng.mapper.StrictBodyConverter;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.rbac.generated.api.v1.api.RbacSubjectsApi;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.ApiKeyScopeResource;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacApiKeySubjectInsertResource;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacApiKeySubjectWithOrganizationInsertResource;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacGroupSubjectInsertResource;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacGroupSubjectUpsertResource;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacGroupSubjectWithOrganizationInsertResource;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacGroupSubjectWithOrganizationUpsertResource;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacSubjectCreatedResource;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacSubjectPermissionResource;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacSubjectResource;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.SubjectLastSyncResource;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacUserSubjectInsertResource;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacUserSubjectUpsertResource;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacUserSubjectWithOrganizationInsertResource;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacUserSubjectWithOrganizationUpsertResource;
import net.hostsharing.hsadminng.rbac.generated.api.v1.model.SubjectTypeResource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;

import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import static java.lang.Boolean.TRUE;
import static net.hostsharing.hsadminng.errors.Validate.validate;
import static net.hostsharing.hsadminng.rbac.subject.SubjectType.API_KEY;
import static net.hostsharing.hsadminng.rbac.subject.SubjectType.GROUP;
import static net.hostsharing.hsadminng.rbac.subject.SubjectType.USER;

@RestController
@PreAuthorize("isAuthenticated()")
@SecurityRequirement(name = "bearerAuth")
public class RbacSubjectController implements RbacSubjectsApi {

    private static final String USER_SUBJECT_NAME_PATTERN_MESSAGE_KEY =
            "rbac.user-subject-name-{0}-does-not-match-required-pattern";
    private static final String GROUP_SUBJECT_NAME_PATTERN_MESSAGE_KEY =
            "rbac.group-subject-name-{0}-does-not-match-required-pattern";
    private static final String API_KEY_SUBJECT_NAME_PATTERN_MESSAGE_KEY =
            "rbac.api-key-subject-name-{0}-does-not-match-required-pattern";

    private static final Duration SYNC_REPORT_RETENTION = Duration.ofDays(7);

    @Autowired
    private Context context;

    @Autowired
    private StrictMapper mapper;

    @Autowired
    private StrictBodyConverter strictBodyConverter;

    @Autowired
    private MessageTranslator messageTranslator;

    @Autowired
    private RbacSubjectRepository rbacSubjectRepository;

    @Autowired
    private RealSubjectRepository realSubjectRepository; // visibility bypasses RBAC, suvjects are no objects anyway

    @Autowired
    private SubjectSyncReportRepository subjectSyncReportRepository;

    @Override
    @Transactional
    @Timed("app.rbac.subjects.api.postNewSubject")
    public ResponseEntity<RbacSubjectCreatedResource> postNewSubject(
            final Object body // anyOf in OpenAPI is generated as a Map in an Object, ugly, but it is as it is
    ) {
        context.define();
        if (!context.isGlobalAdmin()) {
            throw new ForbiddenException("only a global-admin may create subjects");
        }

        final var validated = toValidatedSubjectInsert(body);
        final var entity = validated.entity();
        if (entity.getUuid() == null) {
            entity.setUuid(UUID.randomUUID());
        }

        final var saved = rbacSubjectRepository.create(entity);
        final var resource = mapper.map(saved, RbacSubjectCreatedResource.class);
        if (saved.getType() == API_KEY) {
            // the clear-text API-key is only returned once, in this response; just its hash is stored
            final var apiKey = ApiKey.generate(saved.getName());
            rbacSubjectRepository.createApiKey(
                    saved.getUuid(), ApiKey.hash(apiKey), validated.scopeWireNames(), validated.expiresAt());
            resource.setApiKey(apiKey);
            resource.setScopes(validated.scopes());
            resource.setExpiresAt(validated.expiresAt());
        }
        final var uri =
                MvcUriComponentsBuilder.fromController(getClass())
                        .path("/api/rbac/subjects/{id}")
                        .buildAndExpand(saved.getUuid())
                        .toUri();
        return ResponseEntity.created(uri).body(resource);
    }

    // the validated insert resource reduced to the entity plus, for API_KEY subjects,
    // the named endpoint-scopes and the optional expiry timestamp
    private record ValidatedSubjectInsert(
            RbacSubjectEntity entity, List<ApiKeyScopeResource> scopes, OffsetDateTime expiresAt) {

        String[] scopeWireNames() {
            return scopes == null
                    ? new String[0]
                    : scopes.stream().map(ApiKeyScopeResource::getValue).toArray(String[]::new);
        }
    }

    // The `RbacSubjectInsert` request body is an `anyOf`, which the generator emits as a bare `Object`
    // where `@Valid` cannot run. Thus, we validate based on the `type` discriminator (a missing `type`
    // at the API level defaults to 'USER') and the presence of an explicit `organization` to the
    // matching generated member resource, whose OpenAPI schema constraints are then applied.
    private ValidatedSubjectInsert toValidatedSubjectInsert(final Object body) {
        val properties = body instanceof Map<?, ?> map ? map : Map.of();
        val hasExplicitOrganization = properties.containsKey("organization");
        if (API_KEY.name().equals(properties.get("type"))) {
            return hasExplicitOrganization
                    ? toApiKeySubjectInsertWithExplicitOrganization(body)
                    : toApiKeySubjectInsertWithDerivedOrganization(body);
        }
        if (GROUP.name().equals(properties.get("type"))) {
            return new ValidatedSubjectInsert(
                    hasExplicitOrganization
                            ? toGroupSubjectEntityWithExplicitOrganization(body)
                            : toGroupSubjectEntityWithDerivedOrganization(body),
                    null, null);
        }
        return new ValidatedSubjectInsert(
                hasExplicitOrganization
                        ? toUserSubjectEntityWithExplicitOrganization(body)
                        : toUserSubjectEntityWithDerivedOrganization(body),
                null, null);
    }

    private ValidatedSubjectInsert toApiKeySubjectInsertWithDerivedOrganization(final Object body) {
        val resource = convertAndValidate(
                body, RbacApiKeySubjectInsertResource.class, API_KEY_SUBJECT_NAME_PATTERN_MESSAGE_KEY);
        // API_KEY names carry no '-' realm-prefix, thus the organization is derived from the part
        // before the first '.', mirroring the API_KEY branch of the DB-level default trigger
        return new ValidatedSubjectInsert(
                subjectEntity(resource.getUuid(), resource.getName(),
                        Subject.apiKeyOrganizationFromName(resource.getName()), API_KEY),
                resource.getScopes(), resource.getExpiresAt());
    }

    private ValidatedSubjectInsert toApiKeySubjectInsertWithExplicitOrganization(final Object body) {
        // an API_KEY is never referenced by name, thus its organization need not match the name
        val resource = convertAndValidate(
                body, RbacApiKeySubjectWithOrganizationInsertResource.class, API_KEY_SUBJECT_NAME_PATTERN_MESSAGE_KEY);
        return new ValidatedSubjectInsert(
                subjectEntity(resource.getUuid(), resource.getName(), resource.getOrganization(), API_KEY),
                resource.getScopes(), resource.getExpiresAt());
    }

    private RbacSubjectEntity toUserSubjectEntityWithDerivedOrganization(final Object body) {
        val resource = convertAndValidate(body, RbacUserSubjectInsertResource.class, USER_SUBJECT_NAME_PATTERN_MESSAGE_KEY);
        return subjectEntity(resource.getUuid(), resource.getName(), Subject.organizationFromName(resource.getName()), USER);
    }

    private RbacSubjectEntity toUserSubjectEntityWithExplicitOrganization(final Object body) {
        val resource = convertAndValidate(
                body, RbacUserSubjectWithOrganizationInsertResource.class, USER_SUBJECT_NAME_PATTERN_MESSAGE_KEY);
        return subjectEntity(resource.getUuid(), resource.getName(), resource.getOrganization(), USER);
    }

    private RbacSubjectEntity toGroupSubjectEntityWithDerivedOrganization(final Object body) {
        val resource = convertAndValidate(body, RbacGroupSubjectInsertResource.class, GROUP_SUBJECT_NAME_PATTERN_MESSAGE_KEY);
        return subjectEntity(resource.getUuid(), resource.getName(), Subject.organizationFromName(resource.getName()), GROUP);
    }

    private RbacSubjectEntity toGroupSubjectEntityWithExplicitOrganization(final Object body) {
        // JWTs reference groups just by name (Keycloak default), no UUIDs; thus a GROUP subject's
        // organization must remain derivable from the group-name prefix and is validated against it
        val resource = convertAndValidate(
                body, RbacGroupSubjectWithOrganizationInsertResource.class, GROUP_SUBJECT_NAME_PATTERN_MESSAGE_KEY);
        validate("organization, organization derived from the group-name prefix")
                .areEqual(resource.getOrganization(), Subject.organizationFromName(resource.getName()));
        return subjectEntity(resource.getUuid(), resource.getName(), resource.getOrganization(), GROUP);
    }

    // the validated upsert resource reduced to the entity plus the desired activation state
    private record ValidatedSubjectUpsert(RbacSubjectEntity entity, boolean deactivated) {
    }

    // Like `toValidatedSubjectInsert`, but for the `RbacSubjectUpsert` request body of HTTP PUT,
    // which deliberately has no API_KEY variant: API_KEY subjects never stem from Keycloak.
    private ValidatedSubjectUpsert toValidatedSubjectUpsert(final Object body) {
        val properties = body instanceof Map<?, ?> map ? map : Map.of();
        if (API_KEY.name().equals(properties.get("type"))) {
            throw new ValidationException(
                    "subjects of type API_KEY are not subject to synchronization and cannot be created-or-updated"
                            + " via HTTP PUT; create API_KEY subjects via HTTP POST /api/rbac/subjects");
        }
        val hasExplicitOrganization = properties.containsKey("organization");
        if (GROUP.name().equals(properties.get("type"))) {
            return hasExplicitOrganization
                    ? toGroupSubjectUpsertWithExplicitOrganization(body)
                    : toGroupSubjectUpsertWithDerivedOrganization(body);
        }
        return hasExplicitOrganization
                ? toUserSubjectUpsertWithExplicitOrganization(body)
                : toUserSubjectUpsertWithDerivedOrganization(body);
    }

    private ValidatedSubjectUpsert toUserSubjectUpsertWithDerivedOrganization(final Object body) {
        val resource = convertAndValidate(body, RbacUserSubjectUpsertResource.class, USER_SUBJECT_NAME_PATTERN_MESSAGE_KEY);
        return new ValidatedSubjectUpsert(
                subjectEntity(resource.getUuid(), resource.getName(), Subject.organizationFromName(resource.getName()), USER),
                TRUE.equals(resource.getDeactivated()));
    }

    private ValidatedSubjectUpsert toUserSubjectUpsertWithExplicitOrganization(final Object body) {
        val resource = convertAndValidate(
                body, RbacUserSubjectWithOrganizationUpsertResource.class, USER_SUBJECT_NAME_PATTERN_MESSAGE_KEY);
        return new ValidatedSubjectUpsert(
                subjectEntity(resource.getUuid(), resource.getName(), resource.getOrganization(), USER),
                TRUE.equals(resource.getDeactivated()));
    }

    private ValidatedSubjectUpsert toGroupSubjectUpsertWithDerivedOrganization(final Object body) {
        val resource = convertAndValidate(body, RbacGroupSubjectUpsertResource.class, GROUP_SUBJECT_NAME_PATTERN_MESSAGE_KEY);
        return new ValidatedSubjectUpsert(
                subjectEntity(resource.getUuid(), resource.getName(), Subject.organizationFromName(resource.getName()), GROUP),
                TRUE.equals(resource.getDeactivated()));
    }

    private ValidatedSubjectUpsert toGroupSubjectUpsertWithExplicitOrganization(final Object body) {
        // JWTs reference groups just by name (Keycloak default), no UUIDs; thus a GROUP subject's
        // organization must remain derivable from the group-name prefix and is validated against it
        val resource = convertAndValidate(
                body, RbacGroupSubjectWithOrganizationUpsertResource.class, GROUP_SUBJECT_NAME_PATTERN_MESSAGE_KEY);
        validate("organization, organization derived from the group-name prefix")
                .areEqual(resource.getOrganization(), Subject.organizationFromName(resource.getName()));
        return new ValidatedSubjectUpsert(
                subjectEntity(resource.getUuid(), resource.getName(), resource.getOrganization(), GROUP),
                TRUE.equals(resource.getDeactivated()));
    }

    private <R> R convertAndValidate(final Object body, final Class<R> resourceClass, final String namePatternMessageKey) {
        return strictBodyConverter.convertAndValidate(body, resourceClass,
                violation -> subjectNameViolationMessage(violation, namePatternMessageKey));
    }

    private static RbacSubjectEntity subjectEntity(
            final UUID uuid, final String name, final String organization, final SubjectType type) {
        return RbacSubjectEntity.builder().uuid(uuid).name(name).organization(organization).type(type).build();
    }

    private String subjectNameViolationMessage(final ConstraintViolation<?> violation, final String namePatternMessageKey) {
        return violation.getConstraintDescriptor().getAnnotation() instanceof Pattern
                ? messageTranslator.translate(namePatternMessageKey, violation.getInvalidValue())
                : StrictBodyConverter.defaultViolationMessage(violation);
    }

    @Override
    @Transactional
    @Timed("app.rbac.subjects.api.putSubjectByUuid")
    public ResponseEntity<RbacSubjectResource> putSubjectByUuid(
            final UUID subjectUuid,
            final Object body // anyOf in OpenAPI is generated as a Map in an Object, ugly, but it is as it is
    ) {
        context.requireGlobalAdmin("only a global-admin may create or update subjects");

        val validated = toValidatedSubjectUpsert(body);
        val incoming = validated.entity();
        if (incoming.getUuid() != null) {
            validate("UUID from URI-path, UUID from body").areEqual(subjectUuid, incoming.getUuid());
        } else {
            incoming.setUuid(subjectUuid); // default to UUID from URI-path
        }

        // a single call to the DB-level upsert, keyed by uuid; the type is immutable and validated in the DB function
        val created = "created".equals(
                rbacSubjectRepository.upsert(
                        subjectUuid, incoming.getName(), incoming.getOrganization(), incoming.getType().name(),
                        validated.deactivated()));
        val resource = mapper.map(incoming, RbacSubjectResource.class);
        if (created) {
            val uri = MvcUriComponentsBuilder.fromController(getClass())
                    .path("/api/rbac/subjects/{id}")
                    .buildAndExpand(subjectUuid)
                    .toUri();
            return ResponseEntity.created(uri).body(resource);
        }
        return ResponseEntity.ok(resource);
    }

    @Override
    @Transactional
    @Timed("app.rbac.subjects.api.deleteSubjectByUuid")
    public ResponseEntity<Void> deleteSubjectByUuid(
            final UUID subjectUuid,
            final String name,
            final SubjectTypeResource type
    ) {
        context.requireGlobalAdmin("only a global-admin may delete subjects");

        // the subject is identified by the UUID alone; the given name+type are a safeguard against
        // deleting the wrong subject and must match; idempotent no-op for an unknown UUID
        realSubjectRepository.findSubjectByUuidIncludingDeactivated(subjectUuid).ifPresent(subject -> {
            validate("name from query-parameter, name of the subject to delete")
                    .areEqual(name, subject.getName());
            validate("type from query-parameter, type of the subject to delete")
                    .areEqual(SubjectType.valueOf(type.name()), subject.getType());

            // physical delete: removing the rbac.reference row cascades to the subject and, via the
            // rbac.subject delete triggers, to all of its grants; for an API_KEY subject the FK cascade
            // also removes the stored key hash, which permanently revokes the API-key
            rbacSubjectRepository.deleteByUuid(subjectUuid);
        });

        return ResponseEntity.noContent().build();
    }

    @Override
    @Transactional(readOnly = true)
    @Timed("app.rbac.subjects.api.getSingleSubjectByUuid")
    public ResponseEntity<RbacSubjectResource> getSingleSubjectByUuid(
            final String assumedRoles,
            final UUID subjectUuid) {

        context.assumeRoles(assumedRoles);

        final var result = realSubjectRepository.findVisibleSubjectByUuid(subjectUuid)
                .<Subject<?>>map(subject -> subject);
        if (result.isEmpty()) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(mapper.map(result.get(), RbacSubjectResource.class));
    }

    @Override
    @Transactional(readOnly = true)
    @Timed("app.rbac.subjects.api.getListOfSubjects")
    public ResponseEntity<List<RbacSubjectResource>> getListOfSubjects(
            final String assumedRoles,
            final String userName,
            final String organization,
            final SubjectTypeResource type
    ) {
        context.assumeRoles(assumedRoles);

        final var subjectType = type != null ? SubjectType.valueOf(type.name()) : null;
        return ResponseEntity.ok(mapper.mapList(
                realSubjectRepository.findVisibleSubjectsByOptionalNameLikeOrganizationAndType(
                                userName,
                                organization,
                                subjectType),
                RbacSubjectResource.class));
    }

    @Override
    @Transactional(readOnly = true)
    @Timed("app.rbac.subjects.api.getListOfSubjectPermissions")
    public ResponseEntity<List<RbacSubjectPermissionResource>> getListOfSubjectPermissions(
            final String assumedRoles,
            final UUID subjectUuid
    ) {
        context.assumeRoles(assumedRoles);

        return ResponseEntity.ok(mapper.mapList(
                rbacSubjectRepository.findPermissionsOfUserByUuid(subjectUuid),
                RbacSubjectPermissionResource.class));
    }

    @Override
    @Transactional
    @Timed("app.rbac.subjects.api.postSubjectSyncReport")
    @SuppressWarnings("unchecked")
    public ResponseEntity<Void> postSubjectSyncReport(final Map body) {
        // the report is the monitoring heartbeat and its insert prunes every reporter's history
        context.requireGlobalAdmin("only a global-admin may report a subject-sync");

        val now = OffsetDateTime.now();
        // the API-key JWT carries the subject UUID as its name; store the subject-name so last-sync can query by it
        val subjectUuid = UUID.fromString(SecurityContextHolder.getContext().getAuthentication().getName());
        val reportedBy = realSubjectRepository.findSubjectByUuidIncludingDeactivated(subjectUuid)
                .map(RealSubjectEntity::getName)
                .orElseThrow(() -> new ValidationException("unknown reporting subject: " + subjectUuid));
        subjectSyncReportRepository.save(SubjectSyncReportEntity.builder()
                .uuid(UUID.randomUUID())
                .reportedAt(now)
                .reportedBy(reportedBy)
                .statistics((Map<String, Object>) body)
                .build());
        subjectSyncReportRepository.deleteReportedBefore(now.minus(SYNC_REPORT_RETENTION));
        return ResponseEntity.noContent().build();
    }

    @Override
    @PreAuthorize("permitAll()") // public, so external monitoring can poll it without credentials
    @Transactional(readOnly = true)
    @Timed("app.rbac.subjects.api.getLastSubjectSync")
    public ResponseEntity<SubjectLastSyncResource> getLastSubjectSync(final String name, final String interval) {
        val lookback = parseLookbackInterval(interval);
        val latest = subjectSyncReportRepository
                .findFirstByReportedByAndReportedAtGreaterThanOrderByReportedAtDesc(name, OffsetDateTime.now().minus(lookback));
        val result = new SubjectLastSyncResource();
        if (latest != null) {
            result.setTimestamp(latest.getReportedAt());
            result.setStatistics(latest.getStatistics());
        }
        return ResponseEntity.ok(result);
    }

    private static Duration parseLookbackInterval(final String interval) {
        try {
            return Duration.parse(interval);
        } catch (final DateTimeParseException e) {
            throw new ValidationException("interval must be an ISO-8601 duration like PT5M or PT1H, but was: " + interval);
        }
    }
}