AuthBackend.java

package com.cloudforge.core.manager.auth;

import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;

/**
 * CloudForge Manager's Users-page CRUD, abstracted over whichever directory of accounts is
 * currently authoritative — the local H2/Postgres DB, or an AWS Cognito User Pool. {@code
 * AuthService} resolves one {@code AuthBackend} implementation per call (based on the persisted
 * {@code AuthBackendStore.Config#cognitoEnabled()} flag) and delegates its user-management
 * methods ({@code listUsers}/{@code createLocalUser}/{@code updateUser}/{@code revokeSessions}/
 * {@code deleteUser}) through it.
 *
 * <p><b>Deliberately scoped to exactly this — Users-page CRUD — and nothing else.</b> Caller/session
 * resolution during normal request handling ({@code AuthService.resolveCaller}/{@code
 * ensureOidcUser}) branches only on {@code authMode()} (the *sign-in* mechanism: none/alb-oidc/
 * application-oidc) and always resolves through the local {@code manager_user} table regardless of
 * which {@code AuthBackend} is active for account CRUD — a Cognito-backend-enabled account still
 * signs in via whatever OIDC flow {@code authMode} configures, gets upserted into the local table
 * on each successful principal, and its {@code Caller}/role come from that local row. Moving
 * caller/session resolution into this interface would be a separate behavior change.</p>
 *
 * <p>Implementations: {@code LocalH2AuthBackend} (wraps {@code UserStore}+{@code SessionManager})
 * and {@code CognitoAuthBackend} (wraps {@code CognitoUserManagementService}), both in {@code
 * cloudforge-manager} — this interface and its DTOs ({@link AuthAccount}, {@link
 * AuthAccountRequest}, {@link AuthAccountUpdate}) live in {@code cloudforge-core} because they're
 * plain contracts with no Spring/persistence dependency, following the same split as {@code
 * ApplicationSpec}/{@code DatabaseSpec}.</p>
 */
public interface AuthBackend {

    List<AuthAccount> listAccounts();

    /**
     * Same as {@link #listAccounts()}, narrowed by two optional filters: {@code search} (matches
     * {@link AuthAccount#username()}/{@link AuthAccount#email()}/{@link
     * AuthAccount#displayName()}, case-insensitive substring) and {@code role} (exact match).
     * Both {@code null}/blank mean "unfiltered" — equivalent to {@link #listAccounts()}.
     *
     * <p>The default implementation filters in Java over {@link #listAccounts()} — correct for
     * any backend, but only moves the filtering server-side (out of the Angular Users page)
     * without reducing the underlying account-directory calls. {@code LocalH2AuthBackend}
     * overrides this with a database-level query. {@code CognitoAuthBackend} keeps this default
     * deliberately: Cognito's own {@code ListUsers} {@code Filter} syntax has no concept of role
     * (role lives in group membership, not a filterable user attribute), so even an override
     * there couldn't push the {@code role} filter down — only {@code search} could move, with no
     * reduction in Cognito API calls either, the same trade-off {@code
     * CloudFormationInventory#listInstances(String, String)}'s {@code search} parameter
     * documents for AWS's own {@code ListStacks}.</p>
     */
    default List<AuthAccount> listAccounts(String search, String role) {
        String searchLower = search == null || search.isBlank() ? null : search.trim().toLowerCase(Locale.ROOT);
        String roleFilter = role == null || role.isBlank() ? null : role;
        return listAccounts().stream()
            .filter(account -> roleFilter == null || roleFilter.equalsIgnoreCase(account.role()))
            .filter(account -> searchLower == null || matchesSearch(account, searchLower))
            .toList();
    }

    private static boolean matchesSearch(AuthAccount account, String searchLower) {
        return Stream.of(account.username(), account.email(), account.displayName())
            .filter(Objects::nonNull)
            .map(value -> value.toLowerCase(Locale.ROOT))
            .anyMatch(value -> value.contains(searchLower));
    }

    AuthAccount createAccount(AuthAccountRequest request);

    AuthAccount updateAccount(String accountId, AuthAccountUpdate update);

    /** Invalidates every active session/token for this account without deleting it — the local
     *  equivalent of Cognito's {@code AdminUserGlobalSignOut}. Returns the (unchanged) account so
     *  callers can render an up-to-date view without a second lookup. */
    AuthAccount revokeSessions(String accountId);

    void deleteAccount(String accountId);

    /**
     * One-time setup a backend needs before it can accept migrated-in accounts (e.g. Cognito's IAM
     * role groups must exist before a migration starts adding users to them). Called once per
     * migration run, before the first {@link #createAccount}. No-op by default — {@code
     * LocalH2AuthBackend} has nothing to prepare.
     */
    default void prepareForIncomingMigration() {
    }

    /**
     * Finds one account by its {@link AuthAccount#id()}, or empty if this backend has no such
     * account. Exists so callers that only have an id — e.g. Access Control's policy-override
     * editor, given whatever id {@link #listAccounts()} last handed the frontend — can resolve a
     * username/role for display without requiring the account to also exist in Manager's local
     * user table. A Cognito-only account that has never signed in through application-oidc has
     * no local row, so a local-table lookup would report "user not found" for an account
     * {@link #listAccounts()} just returned.
     *
     * <p>Default implementation scans {@link #listAccounts()} — correct for any backend, just not
     * the cheapest possible lookup; override when a backend can look up a single account more
     * directly (see {@code CognitoAuthBackend}).
     */
    default Optional<AuthAccount> findAccount(String accountId) {
        if (accountId == null || accountId.isBlank()) {
            return Optional.empty();
        }
        return listAccounts().stream().filter(account -> accountId.equals(account.id())).findFirst();
    }
}