Subject.java

package net.hostsharing.hsadminng.rbac.subject;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.SuperBuilder;
import lombok.val;
import net.hostsharing.hsadminng.persistence.ImmutableBaseEntity;

import jakarta.persistence.Column;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.UUID;

@MappedSuperclass
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@SuperBuilder(toBuilder = true)
public abstract class Subject<T extends Subject<?> & ImmutableBaseEntity<?>> implements ImmutableBaseEntity<T> {

    private static final int MAX_VALIDITY_DAYS = 21;
    private static final DateTimeFormatter DATE_FORMAT_WITH_FULLHOUR = DateTimeFormatter.ofPattern("MM-dd-yyyy HH");

    @Id
    @GeneratedValue
    private UUID uuid;

    private String name;

    private String organization;

    @Builder.Default
    @Enumerated(EnumType.STRING)
    @Column(name = "type")
    private SubjectType type = SubjectType.USER;

    // mirrors rbac.subject_realm_prefix in 1050-rbac-base.sql
    public static String organizationFromName(final String subjectName) {
        return subjectName.replaceFirst("^/+", "").split("-", 2)[0];
    }

    // API_KEY names use '.' instead of the '-' realm-prefix delimiter; mirrors the API_KEY branch
    // of rbac.subject_default_organization_tf in 1050-rbac-base.sql
    public static String apiKeyOrganizationFromName(final String subjectName) {
        return subjectName.split("\\.", 2)[0];
    }

    public String generateAccessCode() {
        return generateAccessCode(LocalDateTime.now());
    }

    public boolean isValidAccessCode(final String accessCode, final int validityHours) {
        if (validityHours > 24 * MAX_VALIDITY_DAYS) {
            throw new IllegalArgumentException("Max validity (%s days) exceeded.".formatted(MAX_VALIDITY_DAYS));
        }
        if (generateAccessCode(LocalDateTime.now().minus(validityHours, ChronoUnit.HOURS)).equals(accessCode)) {
            return true;
        }
        if (validityHours < 0) {
            return false;
        }
        return isValidAccessCode(accessCode, validityHours - 1);
    }

    String generateAccessCode(final LocalDateTime timestamp) {
        val compound = name + ":" + uuid + ":" + timestamp.format(DATE_FORMAT_WITH_FULLHOUR);
        val code = String.valueOf(1000000 + Math.abs(compound.hashCode()) % 100000);
        return code.substring(1, 4) + ":" + code.substring(4, 7);
    }
}