diff --git a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/ApplicationService.java b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/ApplicationService.java index 4d681c6d1..d7c3134a1 100644 --- a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/ApplicationService.java +++ b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/ApplicationService.java @@ -15,8 +15,15 @@ import org.devlive.grantforge.authz.domain.OAuthClientRepository; import org.devlive.grantforge.authz.domain.ResourceCount; import org.devlive.grantforge.authz.domain.ResourceRepository; +import org.devlive.grantforge.authz.domain.RoleAssignment; +import org.devlive.grantforge.authz.domain.RoleAssignmentRepository; +import org.devlive.grantforge.authz.domain.RoleRepository; +import org.devlive.grantforge.authz.domain.RoleType; +import org.devlive.grantforge.authz.domain.SubjectType; +import org.devlive.grantforge.authz.domain.SystemRole; import org.devlive.grantforge.common.error.CommonErrorCode; import org.devlive.grantforge.common.error.GrantForgeException; +import org.devlive.grantforge.identity.domain.UserAccountRepository; import org.devlive.grantforge.persistence.tenant.TenantContext; import org.jspecify.annotations.Nullable; import org.springframework.dao.DataIntegrityViolationException; @@ -44,6 +51,9 @@ public final class ApplicationService private final OAuthClientRepository clients; private final ApplicationEntityRepository entities; private final CatalogAccess access; + private final UserAccountRepository accounts; + private final RoleRepository roles; + private final RoleAssignmentRepository assignments; private final AuditLog audit; private final TransactionTemplate transactions; @@ -55,17 +65,24 @@ public final class ApplicationService * @param clients OAuth clients, which keep their application from being deleted * @param entities the data entities applications declared, which keep them from being deleted too * @param access who may read and change the catalog + * @param accounts users eligible to administer applications + * @param roles roles of the current tenant + * @param assignments role assignments * @param audit records every change * @param transactionManager opens transactions */ public ApplicationService(ApplicationRepository applications, ResourceRepository resources, OAuthClientRepository clients, - ApplicationEntityRepository entities, CatalogAccess access, AuditLog audit, PlatformTransactionManager transactionManager) + ApplicationEntityRepository entities, CatalogAccess access, UserAccountRepository accounts, RoleRepository roles, + RoleAssignmentRepository assignments, AuditLog audit, PlatformTransactionManager transactionManager) { this.clients = requireNonNull(clients, "clients"); this.entities = requireNonNull(entities, "entities"); this.applications = requireNonNull(applications, "applications"); this.resources = requireNonNull(resources, "resources"); this.access = requireNonNull(access, "access"); + this.accounts = requireNonNull(accounts, "accounts"); + this.roles = requireNonNull(roles, "roles"); + this.assignments = requireNonNull(assignments, "assignments"); this.audit = requireNonNull(audit, "audit"); this.transactions = new TransactionTemplate(requireNonNull(transactionManager, "transactionManager")); } @@ -81,7 +98,10 @@ public List list(long actorId) return requireNonNull(transactions.execute(status -> { Map counts = resources.countByApplication().stream() .collect(Collectors.toMap(ResourceCount::applicationId, ResourceCount::resources)); + boolean scoped = access.scoped(actorId); return applications.findOrdered().stream() + .filter(application -> !scoped || (!application.isBuiltin() + && Long.valueOf(actorId).equals(application.getAdministratorId()))) .map(application -> ApplicationView.from(application, counts.getOrDefault(application.requireId(), 0L))) .toList(); })); @@ -98,6 +118,21 @@ public List list(long actorId) * @throws GrantForgeException with {@link AuthzErrorCode#APPLICATION_CODE_TAKEN} or {@link CommonErrorCode#BAD_REQUEST} */ public ApplicationView create(long actorId, @Nullable String code, @Nullable String name, @Nullable String description) + { + return create(actorId, code, name, description, null); + } + + /** + * Creates an application with an optional administrator. + * @param actorId the actor + * @param code application code + * @param name application name + * @param description application description + * @param administratorId designated account, if any + * @return the created application + */ + public ApplicationView create(long actorId, @Nullable String code, @Nullable String name, @Nullable String description, + @Nullable Long administratorId) { Application application = audited(() -> write(actorId, () -> { Application created = Catalog.valid(() -> Application.create(String.valueOf(code), String.valueOf(name), @@ -106,7 +141,10 @@ public ApplicationView create(long actorId, @Nullable String code, @Nullable Str throw new GrantForgeException(AuthzErrorCode.APPLICATION_CODE_TAKEN, "application code taken", created.getCode()); }); - return applications.saveAndFlush(created); + assignAdministrator(created, administratorId); + Application saved = applications.saveAndFlush(created); + ensureAdministratorRole(administratorId); + return saved; }), made -> record(AuditAction.APPLICATION_CREATED, actorId, made)); return ApplicationView.from(application, 0); } @@ -123,14 +161,42 @@ public ApplicationView create(long actorId, @Nullable String code, @Nullable Str * {@link CommonErrorCode#BAD_REQUEST} */ public ApplicationView update(long actorId, long id, @Nullable String name, @Nullable String description) + { + return update(actorId, id, name, description, null, false); + } + + /** + * Changes application details and its designated administrator. + * @param actorId the actor + * @param id application ID + * @param name application name + * @param description application description + * @param administratorId designated account, if any + * @return the updated application + */ + public ApplicationView update(long actorId, long id, @Nullable String name, @Nullable String description, + @Nullable Long administratorId) + { + return update(actorId, id, name, description, administratorId, true); + } + + private ApplicationView update(long actorId, long id, @Nullable String name, @Nullable String description, + @Nullable Long administratorId, boolean changeAdministrator) { Application application = audited(() -> write(actorId, () -> { Application found = require(id); + Long previous = found.getAdministratorId(); Catalog.valid(() -> { found.describe(String.valueOf(name), description); return found; }); - return applications.saveAndFlush(found); + if (changeAdministrator) { + assignAdministrator(found, administratorId); + } + Application saved = applications.saveAndFlush(found); + ensureAdministratorRole(found.getAdministratorId()); + removeAdministratorRoleIfUnused(previous); + return saved; }), made -> record(AuditAction.APPLICATION_UPDATED, actorId, made)); return ApplicationView.from(application, requireNonNull(transactions.execute(status -> resources.countByApplication() .stream().filter(count -> count.applicationId() == id).mapToLong(ResourceCount::resources).sum()))); @@ -155,6 +221,8 @@ public void delete(long actorId, long id) throw new GrantForgeException(AuthzErrorCode.APPLICATION_NOT_EMPTY, "application " + id + " has resources or clients"); } applications.delete(found); + applications.flush(); + removeAdministratorRoleIfUnused(found.getAdministratorId()); return found; }), made -> record(AuditAction.APPLICATION_DELETED, actorId, made)); } @@ -173,7 +241,7 @@ public long registerConsole() private Application write(long actorId, Supplier change) { - access.requireEditor(actorId); + access.requirePlatformManager(actorId); try { return requireNonNull(transactions.execute(status -> change.get())); } @@ -188,6 +256,40 @@ private Application require(long id) .orElseThrow(() -> new GrantForgeException(CommonErrorCode.NOT_FOUND, "no application " + id)); } + private void assignAdministrator(Application application, @Nullable Long accountId) + { + if (accountId != null && (!accounts.existsById(accountId) || application.isBuiltin())) { + throw new GrantForgeException(CommonErrorCode.BAD_REQUEST, "invalid application administrator"); + } + application.assignAdministrator(accountId); + } + + private void ensureAdministratorRole(@Nullable Long accountId) + { + if (accountId == null) { + return; + } + var role = roles.findByCode(SystemRole.APPLICATION_ADMIN.code()) + .orElseThrow(() -> new GrantForgeException(CommonErrorCode.CONFLICT, "application administrator role missing")); + if (role.getType() != RoleType.SYSTEM) { + throw new GrantForgeException(CommonErrorCode.CONFLICT, "application administrator role is not a system role"); + } + var assignment = assignments.findByRoleIdAndSubjectTypeAndSubjectId(role.requireId(), SubjectType.USER, accountId) + .orElseGet(() -> RoleAssignment.create(role.requireId(), SubjectType.USER, accountId, RoleAssignment.Terms.UNLIMITED)); + assignment.change(RoleAssignment.Terms.UNLIMITED); + assignments.saveAndFlush(assignment); + } + + private void removeAdministratorRoleIfUnused(@Nullable Long accountId) + { + if (accountId == null || applications.existsByAdministratorId(accountId)) { + return; + } + roles.findByCode(SystemRole.APPLICATION_ADMIN.code()).ifPresent(role -> + assignments.findByRoleIdAndSubjectTypeAndSubjectId(role.requireId(), SubjectType.USER, accountId) + .ifPresent(assignments::delete)); + } + private void record(AuditAction action, long actorId, Application application) { audit.recordWithChange(new AuditRecord(action, AuditOutcome.SUCCESS, TenantContext.requireTenantId(), actorId, null, diff --git a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/ApplicationView.java b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/ApplicationView.java index 3d1c7ffaa..908c46a33 100644 --- a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/ApplicationView.java +++ b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/ApplicationView.java @@ -19,10 +19,24 @@ * @param description the explanation, or {@code null} * @param builtin whether it is part of GrantForge * @param resources how many resources it has + * @param administratorId the designated account, if any */ public record ApplicationView(long id, String code, String name, @Nullable String description, boolean builtin, - long resources) + long resources, @Nullable Long administratorId) { + /** + * Creates a view without a designated administrator. + * @param id application ID + * @param code application code + * @param name application name + * @param description application description + * @param builtin whether it is built in + * @param resources resource count + */ + public ApplicationView(long id, String code, String name, @Nullable String description, boolean builtin, long resources) + { + this(id, code, name, description, builtin, resources, null); + } /** Validates the values. */ public ApplicationView { @@ -40,6 +54,6 @@ public record ApplicationView(long id, String code, String name, @Nullable Strin public static ApplicationView from(Application application, long resources) { return new ApplicationView(application.requireId(), application.getCode(), application.getName(), - application.getDescription(), application.isBuiltin(), resources); + application.getDescription(), application.isBuiltin(), resources, application.getAdministratorId()); } } diff --git a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/AuthorizationEvaluator.java b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/AuthorizationEvaluator.java index ef4449146..a6730ec03 100644 --- a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/AuthorizationEvaluator.java +++ b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/AuthorizationEvaluator.java @@ -276,7 +276,8 @@ Set grantableResources(long accountId, long applicationId) Instant now = clock.instant(); List held = inherited(activeRoles(accountId, now)); boolean console = applications.findById(applicationId).map(Application::getCode).filter(Application.CONSOLE::equals).isPresent(); - if (!console && held.stream().anyMatch(role -> role.type() == RoleType.SYSTEM)) { + if (!console && held.stream().anyMatch(role -> role.type() == RoleType.SYSTEM + && (role.code().equals(SystemRole.TENANT_ADMIN.code()) || role.code().equals(SystemRole.PLATFORM_ADMIN.code())))) { return resources.findTree(applicationId).stream().map(Resource::requireId).collect(Collectors.toSet()); } return usable(held, applicationId, now).keySet(); @@ -440,7 +441,12 @@ private static List systemModules(List roles, CatalogView catalo Set modules = roles.stream().filter(role -> role.type() == RoleType.SYSTEM) .flatMap(role -> SystemRole.byCode(role.code()).stream()).flatMap(role -> role.modules().stream()) .collect(Collectors.toSet()); - return catalog.tree().stream().filter(resource -> resource.getParentId() == null && modules.contains(resource.getCode())) + boolean applicationAdmin = roles.stream().anyMatch(role -> role.type() == RoleType.SYSTEM + && role.code().equals(SystemRole.APPLICATION_ADMIN.code())); + Set actions = Set.of("platform.resource.btn.create", "platform.resource.btn.edit", "platform.resource.btn.move", + "platform.resource.btn.delete", "platform.resource.btn.dependencies", "platform.resource.btn.app-clients"); + return catalog.tree().stream().filter(resource -> (resource.getParentId() == null && modules.contains(resource.getCode())) + || (applicationAdmin && actions.contains(resource.getCode()))) .map(Resource::requireId).toList(); } diff --git a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/CatalogAccess.java b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/CatalogAccess.java index 4be5a8cf2..1bec0304b 100644 --- a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/CatalogAccess.java +++ b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/CatalogAccess.java @@ -5,12 +5,18 @@ package org.devlive.grantforge.authz.application; +import org.devlive.grantforge.authz.domain.Application; +import org.devlive.grantforge.authz.domain.ApplicationRepository; +import org.devlive.grantforge.authz.domain.ResourceDependencyRepository; +import org.devlive.grantforge.authz.domain.ResourceRepository; +import org.devlive.grantforge.authz.domain.SystemRole; import org.devlive.grantforge.common.error.CommonErrorCode; import org.devlive.grantforge.common.error.GrantForgeException; import org.devlive.grantforge.identity.domain.Tenant; import org.devlive.grantforge.identity.domain.TenantRepository; import org.devlive.grantforge.identity.domain.UserAccount; import org.devlive.grantforge.identity.domain.UserAccountRepository; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Component; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.support.TransactionTemplate; @@ -28,18 +34,32 @@ public final class CatalogAccess private final UserAccountRepository accounts; private final TenantRepository tenants; private final TransactionTemplate transactions; + private final ApplicationRepository applications; + private final ObjectProvider authorization; + private final ResourceRepository resources; + private final ResourceDependencyRepository dependencies; /** * Creates the check. * * @param accounts user accounts of the bound tenant * @param tenants tenants, to tell the platform tenant apart + * @param applications the shared application catalog + * @param resources resources in the catalog + * @param dependencies dependencies in the catalog + * @param authorization evaluates the actor's active roles * @param transactionManager opens transactions */ - public CatalogAccess(UserAccountRepository accounts, TenantRepository tenants, PlatformTransactionManager transactionManager) + public CatalogAccess(UserAccountRepository accounts, TenantRepository tenants, ApplicationRepository applications, + ResourceRepository resources, ResourceDependencyRepository dependencies, + ObjectProvider authorization, PlatformTransactionManager transactionManager) { this.accounts = requireNonNull(accounts, "accounts"); this.tenants = requireNonNull(tenants, "tenants"); + this.applications = requireNonNull(applications, "applications"); + this.authorization = requireNonNull(authorization, "authorization"); + this.resources = requireNonNull(resources, "resources"); + this.dependencies = requireNonNull(dependencies, "dependencies"); this.transactions = new TransactionTemplate(requireNonNull(transactionManager, "transactionManager")); } @@ -57,4 +77,81 @@ public void requireEditor(long actorId) throw new GrantForgeException(CommonErrorCode.FORBIDDEN, "account " + actorId + " is not of the platform tenant"); } } + + /** + * Returns whether the actor's application administrator role restricts its catalog access. + * @param actorId the account + * @return whether application scoping applies + */ + public boolean scoped(long actorId) + { + AuthorizationEvaluator evaluator = authorization.getIfAvailable(); + if (evaluator == null) { + return false; + } + var roles = evaluator.snapshot(actorId).roles(); + return roles.contains(SystemRole.APPLICATION_ADMIN.code()) && !roles.contains(SystemRole.PLATFORM_ADMIN.code()); + } + + /** + * Restricts application administrators to applications explicitly assigned to them. + * @param actorId the account + * @param applicationId the application + * @throws GrantForgeException if the application is not assigned + */ + public void requireApplication(long actorId, long applicationId) + { + if (scoped(actorId)) { + boolean allowed = Boolean.TRUE.equals(transactions.execute(status -> applications.findById(applicationId) + .filter(app -> !app.isBuiltin()).map(Application::getAdministratorId) + .filter(id -> id == actorId).isPresent())); + if (!allowed) { + throw new GrantForgeException(CommonErrorCode.FORBIDDEN, "application not assigned to account " + actorId); + } + } + } + + /** + * Requires a platform administrator for application lifecycle operations. + * @param actorId the account + * @throws GrantForgeException if the account is not a platform administrator + */ + public void requirePlatformManager(long actorId) + { + requireEditor(actorId); + boolean system = Boolean.TRUE.equals(transactions.execute(status -> accounts.findById(actorId) + .map(UserAccount::isSystemAccount).orElse(false))); + AuthorizationEvaluator evaluator = authorization.getIfAvailable(); + if (!system && (evaluator == null || !evaluator.snapshot(actorId).roles().contains(SystemRole.PLATFORM_ADMIN.code()))) { + throw new GrantForgeException(CommonErrorCode.FORBIDDEN, "platform administrator required"); + } + } + + /** + * Checks the application owning a resource. + * @param actorId the account + * @param resourceId the resource + */ + public void requireResource(long actorId, long resourceId) + { + if (scoped(actorId)) { + long app = requireNonNull(transactions.execute(status -> resources.findById(resourceId) + .orElseThrow(() -> new GrantForgeException(CommonErrorCode.NOT_FOUND, "resource not found")).getApplicationId())); + requireApplication(actorId, app); + } + } + + /** + * Checks the application owning a resource dependency. + * @param actorId the account + * @param dependencyId the dependency + */ + public void requireDependency(long actorId, long dependencyId) + { + if (scoped(actorId)) { + long app = requireNonNull(transactions.execute(status -> dependencies.findById(dependencyId) + .orElseThrow(() -> new GrantForgeException(CommonErrorCode.NOT_FOUND, "dependency not found")).getApplicationId())); + requireApplication(actorId, app); + } + } } diff --git a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/DependencyService.java b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/DependencyService.java index c4feb8867..15e4ef076 100644 --- a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/DependencyService.java +++ b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/DependencyService.java @@ -79,7 +79,7 @@ public DependencyService(ResourceDependencyRepository dependencies, ResourceRepo public ResourceDependencies of(long actorId, long resourceId) { return requireNonNull(transactions.execute(status -> { - require(resourceId); + access.requireApplication(actorId, require(resourceId).getApplicationId()); return new ResourceDependencies(views(dependencies.findByResourceId(resourceId)), views(dependencies.findByDependsOnId(resourceId))); })); @@ -95,6 +95,7 @@ public ResourceDependencies of(long actorId, long resourceId) */ public List graph(long actorId, long applicationId) { + access.requireApplication(actorId, applicationId); return requireNonNull(transactions.execute(status -> { if (!applications.existsById(applicationId)) { throw new GrantForgeException(CommonErrorCode.NOT_FOUND, "no application " + applicationId); @@ -120,6 +121,7 @@ public DependencyView add(long actorId, long resourceId, long dependsOnId, Depen requireNonNull(kind, "kind"); ResourceDependency dependency = audited(() -> write(actorId, () -> { Resource resource = require(resourceId); + access.requireApplication(actorId, resource.getApplicationId()); Resource target = require(dependsOnId); ResourceDependency created; try { @@ -154,6 +156,7 @@ public DependencyView changeKind(long actorId, long id, DependencyKind kind) requireNonNull(kind, "kind"); ResourceDependency dependency = audited(() -> write(actorId, () -> { ResourceDependency found = requireDependency(id); + access.requireApplication(actorId, found.getApplicationId()); found.changeKind(kind); return dependencies.saveAndFlush(found); }), made -> record(AuditAction.RESOURCE_DEPENDENCY_CHANGED, actorId, made)); @@ -172,6 +175,7 @@ public void remove(long actorId, long id) { audited(() -> write(actorId, () -> { ResourceDependency found = requireDependency(id); + access.requireApplication(actorId, found.getApplicationId()); if (found.getSource() == DependencySource.DECLARED) { throw new GrantForgeException(AuthzErrorCode.DEPENDENCY_DECLARED, "dependency " + id + " is declared"); } diff --git a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/OAuthClientService.java b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/OAuthClientService.java index cdd64cbef..cbedf5332 100644 --- a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/OAuthClientService.java +++ b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/OAuthClientService.java @@ -140,6 +140,7 @@ public List list(long applicationId) public IssuedClient register(long actorId, long applicationId, ClientType type, ClientSettings settings) { access.requireEditor(actorId); + access.requireApplication(actorId, applicationId); Instant now = clock.instant(); String secret = type == ClientType.CONFIDENTIAL ? token(SECRET_BYTES) : null; OAuthClientView view = audited(() -> { @@ -173,6 +174,7 @@ public OAuthClientView update(long actorId, long id, ClientSettings settings) Instant now = clock.instant(); return audited(() -> { OAuthClient client = require(id); + access.requireApplication(actorId, client.getApplicationId()); List issues = new ArrayList<>(); check(client.getType(), settings, issues); boolean wasEnabled = client.isEnabled(); @@ -205,6 +207,7 @@ public IssuedClient rotateSecret(long actorId, long id, Duration grace) String secret = token(SECRET_BYTES); OAuthClientView view = audited(() -> { OAuthClient client = require(id); + access.requireApplication(actorId, client.getApplicationId()); if (client.getType() != ClientType.CONFIDENTIAL) { throw invalid(List.of(FieldIssue.of("type", "error.client.public-no-secret"))); } @@ -228,6 +231,7 @@ public void delete(long actorId, long id) Instant now = clock.instant(); audited(() -> { OAuthClient client = require(id); + access.requireApplication(actorId, client.getApplicationId()); OAuthClientView view = OAuthClientView.from(client, now); events.publishEvent(new ClientAccessEnded(client.requireId(), client.getClientId())); clients.delete(client); diff --git a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/ResourceService.java b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/ResourceService.java index a69d6a164..1a6c6fdc1 100644 --- a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/ResourceService.java +++ b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/ResourceService.java @@ -15,10 +15,16 @@ import org.devlive.grantforge.authz.domain.ResourceDetails; import org.devlive.grantforge.authz.domain.ResourceRepository; import org.devlive.grantforge.authz.domain.ResourceType; +import org.devlive.grantforge.authz.domain.Role; +import org.devlive.grantforge.authz.domain.RoleGrant; import org.devlive.grantforge.authz.domain.RoleGrantRepository; +import org.devlive.grantforge.authz.domain.RoleRepository; +import org.devlive.grantforge.authz.domain.UserResourceGrant; import org.devlive.grantforge.authz.domain.UserResourceGrantRepository; import org.devlive.grantforge.common.error.CommonErrorCode; import org.devlive.grantforge.common.error.GrantForgeException; +import org.devlive.grantforge.identity.domain.Tenant; +import org.devlive.grantforge.identity.domain.TenantRepository; import org.devlive.grantforge.persistence.authz.AuthorizationChanges; import org.devlive.grantforge.persistence.tenant.TenantContext; import org.jspecify.annotations.Nullable; @@ -29,10 +35,14 @@ import org.springframework.transaction.support.TransactionTemplate; import java.util.ArrayList; +import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.Supplier; +import java.util.stream.Collectors; import static java.util.Objects.requireNonNull; @@ -48,6 +58,8 @@ public final class ResourceService private final ResourceDependencyRepository dependencies; private final RoleGrantRepository grants; private final UserResourceGrantRepository directGrants; + private final RoleRepository roles; + private final TenantRepository tenants; private final ApplicationRepository applications; private final CatalogAccess access; private final AuditLog audit; @@ -61,6 +73,8 @@ public final class ResourceService * @param dependencies dependencies, which keep needed resources from being deleted * @param grants grants of every tenant, which keep granted resources from being deleted * @param directGrants user grants which also protect a resource + * @param roles roles referenced by grants + * @param tenants tenants owning the roles * @param applications applications, to check that one exists * @param access who may read and change the catalog * @param audit records every change @@ -68,12 +82,15 @@ public final class ResourceService * @param changes notes changes of what permissions are worked out from */ public ResourceService(ResourceRepository resources, ResourceDependencyRepository dependencies, RoleGrantRepository grants, UserResourceGrantRepository directGrants, + RoleRepository roles, TenantRepository tenants, ApplicationRepository applications, CatalogAccess access, AuditLog audit, PlatformTransactionManager transactionManager, AuthorizationChanges changes) { this.changes = requireNonNull(changes, "changes"); this.grants = requireNonNull(grants, "grants"); this.directGrants = requireNonNull(directGrants, "directGrants"); + this.roles = requireNonNull(roles, "roles"); + this.tenants = requireNonNull(tenants, "tenants"); this.resources = requireNonNull(resources, "resources"); this.dependencies = requireNonNull(dependencies, "dependencies"); this.applications = requireNonNull(applications, "applications"); @@ -95,6 +112,7 @@ public ResourceService(ResourceRepository resources, ResourceDependencyRepositor */ public List tree(long actorId, long applicationId) { + access.requireApplication(actorId, applicationId); return requireNonNull(transactions.execute(status -> { requireApplication(applicationId); return resources.findTree(applicationId).stream().map(ResourceView::from).toList(); @@ -119,6 +137,7 @@ public ResourceView create(long actorId, long applicationId, @Nullable Long pare @Nullable String code, ResourceDetails details) { requireNonNull(type, "type"); + access.requireApplication(actorId, applicationId); Resource resource = audited(() -> write(actorId, () -> { requireApplication(applicationId); Resource parent = parentId == null ? null : require(parentId); @@ -155,6 +174,7 @@ public ResourceView update(long actorId, long id, @Nullable String code, Resourc { Resource resource = audited(() -> write(actorId, () -> { Resource found = require(id); + access.requireApplication(actorId, found.getApplicationId()); String newCode = String.valueOf(code).strip(); if (!newCode.equals(found.getCode())) { if (found.isBuiltin()) { @@ -189,6 +209,7 @@ public ResourceView move(long actorId, long id, @Nullable Long parentId, int pos { Resource resource = audited(() -> write(actorId, () -> { Resource moving = require(id); + access.requireApplication(actorId, moving.getApplicationId()); long applicationId = moving.getApplicationId(); Resource parent = parentId == null ? null : require(parentId); if (parent != null && parent.getApplicationId() != applicationId) { @@ -224,6 +245,7 @@ public void delete(long actorId, long id) { audited(() -> write(actorId, () -> { Resource found = require(id); + access.requireApplication(actorId, found.getApplicationId()); if (found.isBuiltin()) { throw new GrantForgeException(AuthzErrorCode.RESOURCE_PROTECTED, "resource " + id + " is built in"); } @@ -236,16 +258,69 @@ public void delete(long actorId, long id) } // Grants belong to tenants; any tenant's grant keeps the resource. The open session is filtered to the // actor's tenant, so the count runs in a session of its own without the filter. - long granted = requireNonNull(TenantContext.callAsSystem(() -> everyTenant.execute(status -> grants.countByResourceId(id) + directGrants.countByResourceId(id)))); - if (granted > 0) { - throw new GrantForgeException(AuthzErrorCode.RESOURCE_GRANTED, "resource " + id + " is granted", granted); - } + requireNoGrants(List.of(id)); dependencies.deleteAll(dependencies.findByResourceId(id)); resources.delete(found); return found; }), made -> record(AuditAction.RESOURCE_DELETED, actorId, made)); } + /** + * Rejects a deletion when any tenant has explicit role or direct-user grants for the selected resources. + * @param resourceIds resources to delete + * @throws GrantForgeException when grants must be removed first + */ + public void requireNoGrants(Collection resourceIds) + { + if (resourceIds.isEmpty()) { + return; + } + TenantContext.callAsSystem(() -> everyTenant.execute(status -> { + List ids = List.copyOf(resourceIds); + List roleGrants = new ArrayList<>(); + List users = new ArrayList<>(); + for (int offset = 0; offset < ids.size(); offset += 500) { + var batch = ids.subList(offset, Math.min(offset + 500, ids.size())); + roleGrants.addAll(grants.findByResourceIdIn(batch)); + users.addAll(directGrants.findByResourceIdIn(batch)); + } + if (roleGrants.isEmpty() && users.isEmpty()) { + return Boolean.TRUE; + } + Map byId = batches(ids, resources::findAllById).stream().collect(Collectors.toMap(Resource::requireId, value -> value)); + Map byRole = batches(roleGrants.stream().map(RoleGrant::getRoleId).distinct().toList(), roles::findAllById).stream() + .collect(Collectors.toMap(Role::requireId, value -> value)); + Map byTenant = batches(byRole.values().stream().map(Role::getTenantId).filter(Objects::nonNull).distinct().toList(), tenants::findAllById) + .stream().collect(Collectors.toMap(Tenant::requireId, value -> value)); + String roleDetails = roleGrants.stream().map(grant -> { + Resource resource = byId.get(grant.getResourceId()); + Role role = byRole.get(grant.getRoleId()); + Tenant tenant = role == null ? null : byTenant.get(role.getTenantId()); + return resourceLabel(resource, grant.getResourceId()) + " → " + (tenant == null ? "?" : tenant.getCode()) + + " / " + (role == null ? Long.toString(grant.getRoleId()) : role.getName() + " (" + role.getCode() + ")"); + }).distinct().sorted().collect(Collectors.joining("; ")); + String userDetails = users.stream().collect(Collectors.groupingBy(UserResourceGrant::getResourceId, Collectors.counting())) + .entrySet().stream().map(entry -> resourceLabel(byId.get(entry.getKey()), entry.getKey()) + " × " + entry.getValue()) + .sorted().collect(Collectors.joining("; ")); + throw new GrantForgeException(AuthzErrorCode.RESOURCE_GRANTED, "resources are granted", + roleDetails.isEmpty() ? "—" : roleDetails, userDetails.isEmpty() ? "—" : userDetails); + })); + } + + private static List batches(List ids, Function, List> load) + { + List found = new ArrayList<>(); + for (int offset = 0; offset < ids.size(); offset += 500) { + found.addAll(load.apply(ids.subList(offset, Math.min(offset + 500, ids.size())))); + } + return found; + } + + private static String resourceLabel(@Nullable Resource resource, long id) + { + return resource == null ? Long.toString(id) : resource.getDetails().name() + " (" + resource.getCode() + ")"; + } + private void reparent(Resource moving, @Nullable Resource parent) { // Moving below itself or a descendant would detach the subtree into a cycle. diff --git a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/SystemRoleProvisioner.java b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/SystemRoleProvisioner.java index 733d83792..9af458922 100644 --- a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/SystemRoleProvisioner.java +++ b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/application/SystemRoleProvisioner.java @@ -128,7 +128,9 @@ private int create(boolean platform) role = roles.saveAndFlush(Role.system(definition)); created++; } - created += assign(role, administrators); + if (definition != SystemRole.APPLICATION_ADMIN) { + created += assign(role, administrators); + } } return created; })); diff --git a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/domain/Application.java b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/domain/Application.java index 050a5259f..e0fe7beae 100644 --- a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/domain/Application.java +++ b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/domain/Application.java @@ -51,6 +51,9 @@ public class Application @Column(name = "builtin", nullable = false) private boolean builtin; + @Column(name = "administrator_id") + private @Nullable Long administratorId; + /** For JPA. */ protected Application() { @@ -141,4 +144,24 @@ public boolean isBuiltin() { return builtin; } + + /** + * Returns the designated administrator. + * + * @return account ID, or {@code null} when none is assigned + */ + public @Nullable Long getAdministratorId() + { + return administratorId; + } + + /** + * Changes the designated administrator. + * + * @param accountId account ID, or {@code null} to remove the administrator + */ + public void assignAdministrator(@Nullable Long accountId) + { + administratorId = accountId; + } } diff --git a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/domain/ApplicationRepository.java b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/domain/ApplicationRepository.java index 9fe25020f..8c219627e 100644 --- a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/domain/ApplicationRepository.java +++ b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/domain/ApplicationRepository.java @@ -30,4 +30,11 @@ public interface ApplicationRepository */ @Query("select a from Application a order by a.builtin desc, a.name, a.id") List findOrdered(); + + /** + * Returns whether the account administers at least one application. + * @param administratorId the account + * @return whether it administers an application + */ + boolean existsByAdministratorId(Long administratorId); } diff --git a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/domain/SystemRole.java b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/domain/SystemRole.java index 0fa1fa2f0..47fdadb4e 100644 --- a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/domain/SystemRole.java +++ b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/domain/SystemRole.java @@ -15,6 +15,9 @@ public enum SystemRole /** Administers one tenant: its accounts, organization, roles and data services. Exists in every tenant. */ TENANT_ADMIN("tenant-admin", "Tenant administrator", false, "system", "data"), + /** Manages the catalog of explicitly assigned applications. */ + APPLICATION_ADMIN("application-admin", "Application administrator", true), + /** Administers the platform: tenants and the resource catalog. Exists only in the platform tenant. */ PLATFORM_ADMIN("platform-admin", "Platform administrator", true, "system", "data", "platform"); diff --git a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/domain/UserResourceGrantRepository.java b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/domain/UserResourceGrantRepository.java index 1c86a8e12..95b72e603 100644 --- a/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/domain/UserResourceGrantRepository.java +++ b/core/grantforge-authz/src/main/java/org/devlive/grantforge/authz/domain/UserResourceGrantRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Collection; import java.util.List; /** Tenant-scoped direct user resource grants. */ @@ -32,4 +33,11 @@ public interface UserResourceGrantRepository extends JpaRepository findByResourceIdIn(Collection resourceIds); } diff --git a/core/grantforge-authz/src/main/resources/db/changelog/authz.yaml b/core/grantforge-authz/src/main/resources/db/changelog/authz.yaml index 28f096cd8..268fe8736 100644 --- a/core/grantforge-authz/src/main/resources/db/changelog/authz.yaml +++ b/core/grantforge-authz/src/main/resources/db/changelog/authz.yaml @@ -1924,3 +1924,22 @@ databaseChangeLog: referencedTableName: gf_tenant referencedColumnNames: id constraintName: fk_gf_user_resource_grant_tenant + + - changeSet: + id: authz-0022-application-administrator + author: grantforge + comment: Optional account designated to administer an application. + changes: + - addColumn: + tableName: gf_application + columns: + - column: + name: administrator_id + type: BIGINT + - addForeignKeyConstraint: + baseTableName: gf_application + baseColumnNames: administrator_id + referencedTableName: gf_user_account + referencedColumnNames: id + constraintName: fk_gf_application_administrator + onDelete: SET NULL diff --git a/core/grantforge-authz/src/main/resources/i18n/authz.properties b/core/grantforge-authz/src/main/resources/i18n/authz.properties index f15e50181..478555dbd 100644 --- a/core/grantforge-authz/src/main/resources/i18n/authz.properties +++ b/core/grantforge-authz/src/main/resources/i18n/authz.properties @@ -26,7 +26,7 @@ error.authz.role-not-assignable=You may not give this role. error.authz.assignment-protected=System accounts keep their system roles. error.authz.grant-type-unsupported=Resources of this type cannot be granted; modules follow from what is granted below them. error.authz.grant-not-allowed=Platform resources can only be granted in the platform tenant. -error.authz.resource-granted=Roles grant this resource {0} times; take those grants back first. +error.authz.resource-granted=Role grants: {0}; direct user grants: {1}. Revoke these grants first. error.authz.role-exceeds-actor=This role allows things you do not have yourself, so you cannot give it. error.authz.role-inheritance-cycle=Inheriting from "{0}" would make roles inherit from themselves. error.authz.grant-exceeds-actor=You can only allow what you have yourself; you do not have "{0}". diff --git a/core/grantforge-authz/src/main/resources/i18n/authz_zh_CN.properties b/core/grantforge-authz/src/main/resources/i18n/authz_zh_CN.properties index 385e9ffc3..49f1dd818 100644 --- a/core/grantforge-authz/src/main/resources/i18n/authz_zh_CN.properties +++ b/core/grantforge-authz/src/main/resources/i18n/authz_zh_CN.properties @@ -26,7 +26,7 @@ error.authz.role-not-assignable=你不能分配这个角色。 error.authz.assignment-protected=系统账号的系统角色不能移除。 error.authz.grant-type-unsupported=这类资源不能直接授权;模块会根据其下已授权的资源自动可见。 error.authz.grant-not-allowed=平台资源只能在平台租户中授权。 -error.authz.resource-granted=该资源仍被角色授权 {0} 次,请先撤销这些授权。 +error.authz.resource-granted=角色授权:{0};用户直接授权:{1}。请先撤销这些授权。 error.authz.role-exceeds-actor=该角色包含你自己没有的权限,你不能分配它。 error.authz.role-inheritance-cycle=继承“{0}”会形成循环继承。 error.authz.grant-exceeds-actor=你只能授予自己拥有的权限,你没有“{0}”。 diff --git a/core/grantforge-authz/src/test/java/org/devlive/grantforge/authz/application/SystemRoleProvisionerTest.java b/core/grantforge-authz/src/test/java/org/devlive/grantforge/authz/application/SystemRoleProvisionerTest.java index 8da1e9866..160a48f5c 100644 --- a/core/grantforge-authz/src/test/java/org/devlive/grantforge/authz/application/SystemRoleProvisionerTest.java +++ b/core/grantforge-authz/src/test/java/org/devlive/grantforge/authz/application/SystemRoleProvisionerTest.java @@ -89,7 +89,7 @@ void newTenantsGetTheirSystemRolesAndStartUpFillsInMissingOnes() assertThat(codes(platform)).isEmpty(); provisioner.run(new DefaultApplicationArguments()); - assertThat(codes(platform)).containsExactly("platform-admin", "tenant-admin"); + assertThat(codes(platform)).containsExactly("application-admin", "platform-admin", "tenant-admin"); assertThat(provisioner.provision(acme, false)).isZero(); assertThat(codes(acme)).containsExactly("tenant-admin"); // System accounts have the system roles; other accounts do not. diff --git a/core/grantforge-authz/src/test/java/org/devlive/grantforge/authz/domain/ApplicationRepositoryTest.java b/core/grantforge-authz/src/test/java/org/devlive/grantforge/authz/domain/ApplicationRepositoryTest.java index f0aeab5eb..ce45a8b9e 100644 --- a/core/grantforge-authz/src/test/java/org/devlive/grantforge/authz/domain/ApplicationRepositoryTest.java +++ b/core/grantforge-authz/src/test/java/org/devlive/grantforge/authz/domain/ApplicationRepositoryTest.java @@ -5,6 +5,11 @@ package org.devlive.grantforge.authz.domain; +import org.devlive.grantforge.identity.domain.Tenant; +import org.devlive.grantforge.identity.domain.TenantRepository; +import org.devlive.grantforge.identity.domain.UserAccount; +import org.devlive.grantforge.identity.domain.UserAccountRepository; +import org.devlive.grantforge.persistence.tenant.TenantContext; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -13,6 +18,8 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -23,6 +30,12 @@ class ApplicationRepositoryTest @Autowired private ApplicationRepository applications; + @Autowired + private TenantRepository tenants; + + @Autowired + private UserAccountRepository accounts; + @AfterEach void deleteRows() { @@ -50,4 +63,26 @@ void codesAreUnique() assertThatThrownBy(() -> applications.saveAndFlush(Application.create("crm", "Other", null))) .isInstanceOf(DataIntegrityViolationException.class); } + + @Test + void persistsAdministratorAndClearsReferenceWhenAccountIsDeleted() + { + Tenant tenant = tenants.save(Tenant.create("admin-test", "Administrator test")); + try { + TenantContext.runInTenant(tenant.requireId(), () -> { + UserAccount user = accounts.saveAndFlush(UserAccount.create("application-admin", "hash", Instant.EPOCH)); + Application application = Application.create("admin-test-app", "Application", null); + application.assignAdministrator(user.requireId()); + long id = applications.saveAndFlush(application).requireId(); + assertThat(applications.findById(id).orElseThrow().getAdministratorId()).isEqualTo(user.requireId()); + accounts.delete(user); + accounts.flush(); + assertThat(applications.findById(id).orElseThrow().getAdministratorId()).isNull(); + }); + } + finally { + applications.deleteAllInBatch(); + tenants.delete(tenant); + } + } } diff --git a/core/grantforge-authz/src/test/java/org/devlive/grantforge/authz/domain/ApplicationTest.java b/core/grantforge-authz/src/test/java/org/devlive/grantforge/authz/domain/ApplicationTest.java index f5366c0cc..853e92d42 100644 --- a/core/grantforge-authz/src/test/java/org/devlive/grantforge/authz/domain/ApplicationTest.java +++ b/core/grantforge-authz/src/test/java/org/devlive/grantforge/authz/domain/ApplicationTest.java @@ -20,6 +20,11 @@ void normalizesCodeNameAndDescription() assertThat(application.getCode()).isEqualTo("crm"); assertThat(application.getName()).isEqualTo("客户管理"); assertThat(application.getDescription()).isNull(); + assertThat(application.getAdministratorId()).isNull(); + application.assignAdministrator(42L); + assertThat(application.getAdministratorId()).isEqualTo(42L); + application.assignAdministrator(null); + assertThat(application.getAdministratorId()).isNull(); assertThat(application.isBuiltin()).isFalse(); assertThat(application.markBuiltin().isBuiltin()).isTrue(); diff --git a/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ApplicationController.java b/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ApplicationController.java index 55f726ad8..53a282b23 100644 --- a/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ApplicationController.java +++ b/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ApplicationController.java @@ -75,7 +75,8 @@ public List list(@AuthenticationPrincipal SessionUser user) @ResponseStatus(HttpStatus.CREATED) public ApplicationResponse create(@AuthenticationPrincipal SessionUser user, @Valid @RequestBody ApplicationRequest body) { - return ApplicationResponse.from(applications.create(user.accountId(), body.code(), body.name(), body.description())); + return ApplicationResponse.from(applications.create(user.accountId(), body.code(), body.name(), body.description(), + body.administratorId() == null ? null : PathIds.parse(body.administratorId(), "administrator"))); } /** @@ -91,8 +92,13 @@ public ApplicationResponse create(@AuthenticationPrincipal SessionUser user, @Va public ApplicationResponse update(@AuthenticationPrincipal SessionUser user, @PathVariable String id, @Valid @RequestBody ApplicationUpdateRequest body) { - return ApplicationResponse.from(applications.update(user.accountId(), PathIds.parse(id, "application"), body.name(), - body.description())); + long applicationId = PathIds.parse(id, "application"); + String administratorId = body.administratorId(); + if (administratorId == null) { + return ApplicationResponse.from(applications.update(user.accountId(), applicationId, body.name(), body.description())); + } + return ApplicationResponse.from(applications.update(user.accountId(), applicationId, body.name(), body.description(), + administratorId.isBlank() ? null : PathIds.parse(administratorId, "administrator"))); } /** diff --git a/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ApplicationRequest.java b/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ApplicationRequest.java index 74eb64d99..a4508bd89 100644 --- a/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ApplicationRequest.java +++ b/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ApplicationRequest.java @@ -16,10 +16,22 @@ * hyphens or underscores * @param name the display name * @param description an optional explanation + * @param administratorId the designated account, if any */ public record ApplicationRequest( @NotBlank @Size(max = 64) @Nullable String code, @NotBlank @Size(max = 128) @Nullable String name, - @Size(max = 500) @Nullable String description) + @Size(max = 500) @Nullable String description, + @Nullable String administratorId) { + /** + * Creates a request without an administrator. + * @param code application code + * @param name application name + * @param description application description + */ + public ApplicationRequest(@Nullable String code, @Nullable String name, @Nullable String description) + { + this(code, name, description, null); + } } diff --git a/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ApplicationResponse.java b/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ApplicationResponse.java index b529ffd2f..ab7a42177 100644 --- a/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ApplicationResponse.java +++ b/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ApplicationResponse.java @@ -17,10 +17,24 @@ * @param description the explanation, or {@code null} * @param builtin whether it is part of GrantForge (and cannot be deleted) * @param resources how many resources it has + * @param administratorId the designated account, if any */ public record ApplicationResponse(String id, String code, String name, @Nullable String description, boolean builtin, - long resources) + long resources, @Nullable String administratorId) { + /** + * Creates a response without a designated administrator. + * @param id application ID + * @param code application code + * @param name application name + * @param description application description + * @param builtin whether it is built in + * @param resources resource count + */ + public ApplicationResponse(String id, String code, String name, @Nullable String description, boolean builtin, long resources) + { + this(id, code, name, description, builtin, resources, null); + } /** * Converts a view. * @@ -29,7 +43,9 @@ public record ApplicationResponse(String id, String code, String name, @Nullable */ public static ApplicationResponse from(ApplicationView application) { + Long administratorId = application.administratorId(); return new ApplicationResponse(Long.toString(application.id()), application.code(), application.name(), - application.description(), application.builtin(), application.resources()); + application.description(), application.builtin(), application.resources(), + administratorId == null ? null : Long.toString(administratorId)); } } diff --git a/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ApplicationUpdateRequest.java b/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ApplicationUpdateRequest.java index 8b3ba56ce..dad299e98 100644 --- a/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ApplicationUpdateRequest.java +++ b/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ApplicationUpdateRequest.java @@ -14,9 +14,20 @@ * * @param name the display name * @param description the explanation; blank removes it + * @param administratorId account ID, empty to clear, or {@code null} to leave unchanged */ public record ApplicationUpdateRequest( @NotBlank @Size(max = 128) @Nullable String name, - @Size(max = 500) @Nullable String description) + @Size(max = 500) @Nullable String description, + @Nullable String administratorId) { + /** + * Creates a request without an administrator change. + * @param name application name + * @param description application description + */ + public ApplicationUpdateRequest(@Nullable String name, @Nullable String description) + { + this(name, description, null); + } } diff --git a/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ClientController.java b/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ClientController.java index ccdbdfafe..3f91924b1 100644 --- a/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ClientController.java +++ b/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ClientController.java @@ -6,6 +6,7 @@ package org.devlive.grantforge.server.catalog; import jakarta.validation.Valid; +import org.devlive.grantforge.authz.application.CatalogAccess; import org.devlive.grantforge.authz.application.OAuthClientService; import org.devlive.grantforge.common.security.RequirePermission; import org.devlive.grantforge.common.security.RequireStepUp; @@ -41,27 +42,32 @@ public final class ClientController { private final OAuthClientService clients; + private final CatalogAccess access; /** * Creates the controller. * * @param clients the clients + * @param access application access checks */ - public ClientController(OAuthClientService clients) + public ClientController(OAuthClientService clients, CatalogAccess access) { this.clients = requireNonNull(clients, "clients"); + this.access = requireNonNull(access, "access"); } /** * Lists an application's clients, oldest first. * + * @param user the session principal * @param id the application * @return the clients */ @RequirePermission("platform.client.read") @GetMapping("/applications/{id}/clients") - public List applicationClients(@PathVariable String id) + public List applicationClients(@AuthenticationPrincipal SessionUser user, @PathVariable String id) { + access.requireApplication(user.accountId(), PathIds.parse(id, "application")); return clients.list(PathIds.parse(id, "application")).stream().map(ClientResponse::from).toList(); } diff --git a/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/FieldUsageController.java b/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/FieldUsageController.java index ccc44d37c..ab470fa6b 100644 --- a/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/FieldUsageController.java +++ b/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/FieldUsageController.java @@ -5,9 +5,12 @@ package org.devlive.grantforge.server.catalog; +import org.devlive.grantforge.authz.application.CatalogAccess; import org.devlive.grantforge.authz.application.DataEntityCatalog; import org.devlive.grantforge.common.security.RequirePermission; +import org.devlive.grantforge.server.security.SessionUser; import org.devlive.grantforge.server.web.PathIds; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; @@ -21,27 +24,32 @@ public final class FieldUsageController { private final DataEntityCatalog entities; + private final CatalogAccess access; /** * Creates the controller. * * @param entities the secured entities and fields of the catalog + * @param access application access checks */ - public FieldUsageController(DataEntityCatalog entities) + public FieldUsageController(DataEntityCatalog entities, CatalogAccess access) { this.entities = requireNonNull(entities, "entities"); + this.access = requireNonNull(access, "access"); } /** * Returns the APIs a secured field appears in. * + * @param user the session principal * @param id the field's resource * @return the APIs, by path and method */ @RequirePermission("platform.catalog.read") @GetMapping("/api/v1/resources/{id}/field-usages") - public List of(@PathVariable String id) + public List of(@AuthenticationPrincipal SessionUser user, @PathVariable String id) { + access.requireResource(user.accountId(), PathIds.parse(id, "resource")); return entities.usages(PathIds.parse(id, "resource")).stream().map(FieldUsageResponse::from).toList(); } } diff --git a/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ImpactController.java b/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ImpactController.java index 1ab55da71..750cbca8b 100644 --- a/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ImpactController.java +++ b/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ImpactController.java @@ -6,11 +6,14 @@ package org.devlive.grantforge.server.catalog; import jakarta.validation.Valid; +import org.devlive.grantforge.authz.application.CatalogAccess; import org.devlive.grantforge.authz.application.ImpactAnalysis; import org.devlive.grantforge.authz.domain.DependencyKind; import org.devlive.grantforge.common.security.RequirePermission; +import org.devlive.grantforge.server.security.SessionUser; import org.devlive.grantforge.server.web.PathIds; import org.jspecify.annotations.Nullable; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -29,28 +32,34 @@ public final class ImpactController { private final ImpactAnalysis impacts; + private final CatalogAccess access; /** * Creates the controller. * * @param impacts works out what changes would do + * @param access application access checks */ - public ImpactController(ImpactAnalysis impacts) + public ImpactController(ImpactAnalysis impacts, CatalogAccess access) { this.impacts = requireNonNull(impacts, "impacts"); + this.access = requireNonNull(access, "access"); } /** * Works out what a new dependency would do. * + * @param user the session principal * @param id the resource that would need the other * @param body the resource it would need, and how strongly * @return the impact */ @RequirePermission("platform.resource.update") @PostMapping("/api/v1/resources/{id}/dependencies/impact") - public ImpactReportResponse newDependency(@PathVariable String id, @Valid @RequestBody DependencyRequest body) + public ImpactReportResponse newDependency(@AuthenticationPrincipal SessionUser user, @PathVariable String id, @Valid @RequestBody DependencyRequest body) { + access.requireResource(user.accountId(), PathIds.parse(id, "resource")); + access.requireResource(user.accountId(), PathIds.parse(String.valueOf(body.dependsOnId()).trim(), "resource")); return ImpactReportResponse.from(impacts.ofNewDependency(PathIds.parse(id, "resource"), PathIds.parse(String.valueOf(body.dependsOnId()).trim(), "resource"), requireNonNullElse(body.kind(), DependencyKind.REQUIRED))); } @@ -58,28 +67,32 @@ public ImpactReportResponse newDependency(@PathVariable String id, @Valid @Reque /** * Works out what changing the kind of a dependency, or removing it, would do. * + * @param user the session principal * @param id the dependency * @param kind its new kind; absent to remove it * @return the impact */ @RequirePermission("platform.resource.update") @GetMapping("/api/v1/resource-dependencies/{id}/impact") - public ImpactReportResponse dependencyChange(@PathVariable String id, @RequestParam(required = false) @Nullable DependencyKind kind) + public ImpactReportResponse dependencyChange(@AuthenticationPrincipal SessionUser user, @PathVariable String id, @RequestParam(required = false) @Nullable DependencyKind kind) { + access.requireDependency(user.accountId(), PathIds.parse(id, "dependency")); return ImpactReportResponse.from(impacts.ofDependencyChange(PathIds.parse(id, "dependency"), kind)); } /** * Works out what enabling or disabling a resource would do. * + * @param user the session principal * @param id the resource * @param enabled whether it would be enabled * @return the impact */ @RequirePermission("platform.resource.update") @GetMapping("/api/v1/resources/{id}/impact") - public ImpactReportResponse enabled(@PathVariable String id, @RequestParam boolean enabled) + public ImpactReportResponse enabled(@AuthenticationPrincipal SessionUser user, @PathVariable String id, @RequestParam boolean enabled) { + access.requireResource(user.accountId(), PathIds.parse(id, "resource")); return ImpactReportResponse.from(impacts.ofEnabled(PathIds.parse(id, "resource"), enabled)); } } diff --git a/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ResourceTreeImportController.java b/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ResourceTreeImportController.java index e59807f10..fb1c8ca1b 100644 --- a/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ResourceTreeImportController.java +++ b/core/grantforge-server/src/main/java/org/devlive/grantforge/server/catalog/ResourceTreeImportController.java @@ -16,6 +16,7 @@ import org.devlive.grantforge.authz.domain.DependencyKind; import org.devlive.grantforge.authz.domain.ResourceDetails; import org.devlive.grantforge.authz.domain.ResourceType; +import org.devlive.grantforge.authz.domain.SystemRole; import org.devlive.grantforge.common.error.CommonErrorCode; import org.devlive.grantforge.common.error.GrantForgeException; import org.devlive.grantforge.common.security.RequirePermission; @@ -97,10 +98,13 @@ public ResourceTreeImportController(ResourceService resources, DependencyService @org.springframework.web.bind.annotation.ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) public void clear(@AuthenticationPrincipal SessionUser user, @PathVariable String id) { - if (!administrators.isPlatformAdministrator(user.accountId())) { + if (!administrators.isPlatformAdministrator(user.accountId()) + && !authorization.snapshot(user.accountId()).roles().contains(SystemRole.PLATFORM_ADMIN.code()) + && !access.scoped(user.accountId())) { throw new GrantForgeException(CommonErrorCode.FORBIDDEN, "platform administrator required"); } long applicationId = PathIds.parse(id, "application"); + access.requireApplication(user.accountId(), applicationId); transactions.executeWithoutResult(status -> { var application = applications.findById(applicationId) .orElseThrow(() -> new GrantForgeException(CommonErrorCode.NOT_FOUND, "application not found")); @@ -108,6 +112,7 @@ public void clear(@AuthenticationPrincipal SessionUser user, @PathVariable Strin throw new GrantForgeException(CommonErrorCode.FORBIDDEN, "built-in application"); } var tree = new ArrayList<>(resources.tree(user.accountId(), applicationId)); + resources.requireNoGrants(tree.stream().map(ResourceView::id).toList()); for (DependencyView dependency : dependencies.graph(user.accountId(), applicationId)) { dependencies.remove(user.accountId(), dependency.id()); } @@ -118,6 +123,68 @@ public void clear(@AuthenticationPrincipal SessionUser user, @PathVariable Strin }); } + /** + * Clears the descendants of a top-level resource without removing the resource itself. + * + * @param user current user + * @param id target application ID + * @param rootId top-level resource ID + * @throws GrantForgeException if access is denied or the descendants cannot be cleared + */ + @RequirePermission("platform.resource.delete") + @DeleteMapping("/{rootId}/children") + @org.springframework.web.bind.annotation.ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) + public void clearChildren(@AuthenticationPrincipal SessionUser user, @PathVariable String id, @PathVariable String rootId) + { + if (!administrators.isPlatformAdministrator(user.accountId()) + && !authorization.snapshot(user.accountId()).roles().contains(SystemRole.PLATFORM_ADMIN.code()) + && !access.scoped(user.accountId())) { + throw new GrantForgeException(CommonErrorCode.FORBIDDEN, "platform administrator required"); + } + long applicationId = PathIds.parse(id, "application"); + access.requireApplication(user.accountId(), applicationId); + long root = PathIds.parse(rootId, "resource"); + transactions.executeWithoutResult(status -> { + var application = applications.findById(applicationId) + .orElseThrow(() -> new GrantForgeException(CommonErrorCode.NOT_FOUND, "application not found")); + if (application.isBuiltin()) { + throw new GrantForgeException(CommonErrorCode.FORBIDDEN, "built-in application"); + } + var tree = resources.tree(user.accountId(), applicationId); + var parent = tree.stream().filter(resource -> resource.id() == root && resource.parentId() == null).findFirst() + .orElseThrow(() -> new GrantForgeException(CommonErrorCode.NOT_FOUND, "top-level resource not found")); + Set subtree = new HashSet<>(); + subtree.add(parent.id()); + boolean changed; + do { + changed = false; + for (ResourceView resource : tree) { + if (resource.parentId() != null && subtree.contains(resource.parentId())) { + changed |= subtree.add(resource.id()); + } + } + } + while (changed); + subtree.remove(root); + resources.requireNoGrants(subtree); + for (DependencyView dependency : dependencies.graph(user.accountId(), applicationId)) { + boolean source = subtree.contains(dependency.resourceId()); + boolean target = subtree.contains(dependency.dependsOnId()); + if (source != target) { + throw new GrantForgeException(CommonErrorCode.CONFLICT, "dependency crosses the selected resource subtree"); + } + if (source) { + dependencies.remove(user.accountId(), dependency.id()); + } + } + var descendants = new ArrayList<>(tree.stream().filter(resource -> subtree.contains(resource.id())).toList()); + Collections.reverse(descendants); + for (ResourceView resource : descendants) { + resources.delete(user.accountId(), resource.id()); + } + }); + } + /** * Validates or applies a version 1 resource tree. * @@ -135,6 +202,7 @@ public Report importTree(@AuthenticationPrincipal SessionUser user, @PathVariabl { long applicationId = PathIds.parse(id, "application"); access.requireEditor(user.accountId()); + access.requireApplication(user.accountId(), applicationId); if (document.path("dependencies").isArray() && !document.path("dependencies").isEmpty() && !authorization.snapshot(user.accountId()).holds("platform.resource.update")) { throw new GrantForgeException(SecurityErrorCode.PERMISSION_DENIED, "dependency permission required"); diff --git a/core/grantforge-server/src/test/java/org/devlive/grantforge/server/catalog/ResourceTreeImportControllerTest.java b/core/grantforge-server/src/test/java/org/devlive/grantforge/server/catalog/ResourceTreeImportControllerTest.java index 9571a2f06..4643f2c88 100644 --- a/core/grantforge-server/src/test/java/org/devlive/grantforge/server/catalog/ResourceTreeImportControllerTest.java +++ b/core/grantforge-server/src/test/java/org/devlive/grantforge/server/catalog/ResourceTreeImportControllerTest.java @@ -16,11 +16,13 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.hasSize; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -97,6 +99,27 @@ void platformAdministratorClearsTreeAndManualDependencies() throws Exception mvc.perform(get("/api/v1/applications/" + app + "/resources").cookie(root)).andExpect(jsonPath("$", hasSize(0))); } + @Test + void platformAdministratorRoleCanClearWithoutSystemAccount() throws Exception + { + Cookie root = flow.login("root"); + String app = application(root, "clear-role-admin"); + String top = CatalogFlow.idOf(flow.createResource(root, app, "{\"type\":\"MODULE\",\"code\":\"root\",\"name\":\"Root\"}") + .andExpect(status().isCreated())); + flow.createResource(root, app, "{\"parentId\":\"%s\",\"type\":\"PAGE\",\"code\":\"child\",\"name\":\"Child\"}".formatted(top)) + .andExpect(status().isCreated()); + jdbc.update("UPDATE gf_user_account SET system_account = false WHERE username = ?", "root"); + try { + mvc.perform(get("/api/v1/me/authorization").cookie(root)).andExpect(jsonPath("$.unrestricted").value(false)); + mvc.perform(delete("/api/v1/applications/" + app + "/resource-tree/" + top + "/children").with(csrf()).cookie(root)) + .andExpect(status().isNoContent()); + mvc.perform(get("/api/v1/applications/" + app + "/resources").cookie(root)).andExpect(jsonPath("$", hasSize(1))); + } + finally { + jdbc.update("UPDATE gf_user_account SET system_account = true WHERE username = ?", "root"); + } + } + @Test void protectedDependencyPreventsClearingTheTree() throws Exception { @@ -109,6 +132,155 @@ void protectedDependencyPreventsClearingTheTree() throws Exception mvc.perform(get("/api/v1/applications/" + app + "/resources").cookie(root)).andExpect(jsonPath("$", hasSize(3))); } + @Test + void clearsOnlyDescendantsAndRequiresPlatformAdministrator() throws Exception + { + Cookie root = flow.login("root"); + String app = application(root, "clear-branch"); + String top = CatalogFlow.idOf(flow.createResource(root, app, "{\"type\":\"MODULE\",\"code\":\"one\",\"name\":\"One\"}") + .andExpect(status().isCreated())); + String page = CatalogFlow.idOf(flow.createResource(root, app, "{\"parentId\":\"%s\",\"type\":\"PAGE\",\"code\":\"one.page\",\"name\":\"Page\"}".formatted(top)) + .andExpect(status().isCreated())); + flow.createResource(root, app, "{\"parentId\":\"%s\",\"type\":\"ACTION\",\"code\":\"one.action\",\"name\":\"Action\"}".formatted(page)) + .andExpect(status().isCreated()); + String api = CatalogFlow.idOf(flow.createResource(root, app, "{\"parentId\":\"%s\",\"type\":\"API\",\"code\":\"one.api\",\"name\":\"API\"}".formatted(top)) + .andExpect(status().isCreated())); + mvc.perform(post("/api/v1/resources/" + page + "/dependencies").with(csrf()).cookie(root).contentType(MediaType.APPLICATION_JSON) + .content("{\"dependsOnId\":\"%s\"}".formatted(api))).andExpect(status().isCreated()); + String other = CatalogFlow.idOf(flow.createResource(root, app, "{\"type\":\"MODULE\",\"code\":\"two\",\"name\":\"Two\"}") + .andExpect(status().isCreated())); + flow.createResource(root, app, "{\"parentId\":\"%s\",\"type\":\"PAGE\",\"code\":\"two.page\",\"name\":\"Other\"}".formatted(other)) + .andExpect(status().isCreated()); + String endpoint = "/api/v1/applications/" + app + "/resource-tree/" + top + "/children"; + mvc.perform(delete(endpoint).with(csrf()).cookie(flow.login("boss"))).andExpect(status().isForbidden()); + mvc.perform(delete("/api/v1/applications/" + flow.consoleId(root) + "/resource-tree/" + top + "/children").with(csrf()).cookie(root)) + .andExpect(status().isForbidden()); + mvc.perform(delete("/api/v1/applications/" + app + "/resource-tree/" + page + "/children").with(csrf()).cookie(root)) + .andExpect(status().isNotFound()); + mvc.perform(delete(endpoint).with(csrf()).cookie(root)).andExpect(status().isNoContent()); + mvc.perform(get("/api/v1/applications/" + app + "/resources").cookie(root)) + .andExpect(jsonPath("$", hasSize(3))) + .andExpect(jsonPath("$[0].id").value(top)).andExpect(jsonPath("$[1].id").value(other)); + } + + @Test + void dependencyOutsideBranchPreventsClearingAndRollsBack() throws Exception + { + Cookie root = flow.login("root"); + String app = application(root, "clear-branch-dependency"); + String top = CatalogFlow.idOf(flow.createResource(root, app, "{\"type\":\"MODULE\",\"code\":\"one\",\"name\":\"One\"}") + .andExpect(status().isCreated())); + String page = CatalogFlow.idOf(flow.createResource(root, app, "{\"parentId\":\"%s\",\"type\":\"PAGE\",\"code\":\"one.page\",\"name\":\"Page\"}".formatted(top)) + .andExpect(status().isCreated())); + String api = CatalogFlow.idOf(flow.createResource(root, app, "{\"type\":\"API\",\"code\":\"outside.api\",\"name\":\"API\"}") + .andExpect(status().isCreated())); + mvc.perform(post("/api/v1/resources/" + page + "/dependencies").with(csrf()).cookie(root).contentType(MediaType.APPLICATION_JSON) + .content("{\"dependsOnId\":\"%s\"}".formatted(api))).andExpect(status().isCreated()); + mvc.perform(delete("/api/v1/applications/" + app + "/resource-tree/" + top + "/children").with(csrf()).cookie(root)) + .andExpect(status().isConflict()); + mvc.perform(get("/api/v1/applications/" + app + "/resources").cookie(root)).andExpect(jsonPath("$", hasSize(3))); + } + + @Test + void declaredDependencyPreventsClearingDescendants() throws Exception + { + Cookie root = flow.login("root"); + String app = application(root, "clear-branch-declared"); + submit(root, app, true, DOCUMENT).andExpect(jsonPath("$.applied").value(true)); + String body = mvc.perform(get("/api/v1/applications/" + app + "/resources").cookie(root)) + .andReturn().getResponse().getContentAsString(); + String top = com.jayway.jsonpath.JsonPath.read(body, "$[0].id"); + jdbc.update("UPDATE gf_resource_dependency SET dependency_source = 'DECLARED' WHERE application_id = ?", Long.parseLong(app)); + mvc.perform(delete("/api/v1/applications/" + app + "/resource-tree/" + top + "/children").with(csrf()).cookie(root)) + .andExpect(status().isConflict()); + mvc.perform(get("/api/v1/applications/" + app + "/resources").cookie(root)).andExpect(jsonPath("$", hasSize(3))); + } + + @Test + void clearingReportsEveryBlockingRoleAndDirectUserGrant() throws Exception + { + Cookie root = flow.login("root"); + Cookie boss = flow.login("boss"); + String app = application(root, "clear-granted-details"); + String top = CatalogFlow.idOf(flow.createResource(root, app, "{\"type\":\"MODULE\",\"code\":\"reports\",\"name\":\"Reports\"}") + .andExpect(status().isCreated())); + String first = CatalogFlow.idOf(flow.createResource(root, app, "{\"parentId\":\"%s\",\"type\":\"PAGE\",\"code\":\"reports.read\",\"name\":\"Read reports\"}".formatted(top)) + .andExpect(status().isCreated())); + String second = CatalogFlow.idOf(flow.createResource(root, app, "{\"parentId\":\"%s\",\"type\":\"PAGE\",\"code\":\"reports.edit\",\"name\":\"Edit reports\"}".formatted(top)) + .andExpect(status().isCreated())); + String platformRole = CatalogFlow.idOf(mvc.perform(post("/api/v1/roles").with(csrf()).cookie(root).contentType(MediaType.APPLICATION_JSON) + .content("{\"code\":\"report-readers\",\"name\":\"Report readers\"}")).andExpect(status().isCreated())); + String tenantRole = CatalogFlow.idOf(mvc.perform(post("/api/v1/roles").with(csrf()).cookie(boss).contentType(MediaType.APPLICATION_JSON) + .content("{\"code\":\"report-editors\",\"name\":\"Report editors\"}")).andExpect(status().isCreated())); + for (String[] grant : new String[][] {{platformRole, first}, {tenantRole, second}}) { + Cookie actor = grant[0].equals(platformRole) ? root : boss; + mvc.perform(put("/api/v1/roles/" + grant[0] + "/grants").with(csrf()).cookie(actor).contentType(MediaType.APPLICATION_JSON) + .content("{\"applicationId\":\"%s\",\"changes\":[{\"resourceId\":\"%s\",\"effect\":\"ALLOW\"}]}".formatted(app, grant[1]))) + .andExpect(status().isOk()); + } + String rootId = com.jayway.jsonpath.JsonPath.read(mvc.perform(get("/api/v1/users").param("q", "root").cookie(root)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.items[0].id"); + mvc.perform(put("/api/v1/users/" + rootId + "/resource-grants").with(csrf()).cookie(root).contentType(MediaType.APPLICATION_JSON) + .content("{\"applicationId\":\"%s\",\"changes\":[{\"resourceId\":\"%s\",\"effect\":\"ALLOW\"}]}".formatted(app, first))) + .andExpect(status().isOk()); + mvc.perform(delete("/api/v1/applications/" + app + "/resource-tree/" + top + "/children").with(csrf()).cookie(root) + .header("Accept-Language", "zh-CN")) + .andExpect(status().isConflict()).andExpect(jsonPath("$.detail", containsString("Report readers"))) + .andExpect(jsonPath("$.detail", containsString("Report editors"))) + .andExpect(jsonPath("$.detail", containsString("reports.read"))) + .andExpect(jsonPath("$.detail", containsString("reports.edit"))) + .andExpect(jsonPath("$.detail", containsString("用户直接授权"))) + .andExpect(jsonPath("$.detail", containsString("acme"))); + mvc.perform(get("/api/v1/applications/" + app + "/resources").cookie(root)).andExpect(jsonPath("$", hasSize(3))); + } + + @Test + void applicationAdministratorIsRestrictedToAssignedApplication() throws Exception + { + Cookie root = flow.login("root"); + String user = com.jayway.jsonpath.JsonPath.read(mvc.perform(post("/api/v1/users").with(csrf()).cookie(root) + .contentType(MediaType.APPLICATION_JSON).content("{\"username\":\"app-manager\",\"password\":\"%s\",\"profile\":{}}".formatted(CatalogFlow.PASSWORD))) + .andExpect(status().isCreated()).andReturn().getResponse().getContentAsString(), "$.user.id"); + String managed = CatalogFlow.idOf(mvc.perform(post("/api/v1/applications").with(csrf()).cookie(root) + .contentType(MediaType.APPLICATION_JSON).content("{\"code\":\"managed-app\",\"name\":\"Managed\",\"administratorId\":\"%s\"}".formatted(user))) + .andExpect(status().isCreated()).andExpect(jsonPath("$.administratorId").value(user))); + String other = application(root, "other-app"); + String otherResource = CatalogFlow.idOf(flow.createResource(root, other, "{\"type\":\"MODULE\",\"code\":\"other\",\"name\":\"Other\"}") + .andExpect(status().isCreated())); + Cookie manager = flow.login("app-manager"); + mvc.perform(post("/api/v1/me/password").with(csrf()).cookie(manager).contentType(MediaType.APPLICATION_JSON) + .content("{\"currentPassword\":\"%s\",\"newPassword\":\"%s!\"}".formatted(CatalogFlow.PASSWORD, CatalogFlow.PASSWORD))) + .andExpect(status().isNoContent()); + mvc.perform(get("/api/v1/me/authorization").cookie(manager)) + .andExpect(jsonPath("$.roles[0]").value("application-admin")); + mvc.perform(get("/api/v1/applications").cookie(manager)).andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))).andExpect(jsonPath("$[0].id").value(managed)); + String top = CatalogFlow.idOf(flow.createResource(manager, managed, "{\"type\":\"MODULE\",\"code\":\"managed\",\"name\":\"Managed\"}") + .andExpect(status().isCreated())); + flow.createResource(manager, managed, "{\"parentId\":\"%s\",\"type\":\"PAGE\",\"code\":\"managed.page\",\"name\":\"Page\"}".formatted(top)) + .andExpect(status().isCreated()); + mvc.perform(delete("/api/v1/applications/" + managed + "/resource-tree/" + top + "/children").with(csrf()).cookie(manager)) + .andExpect(status().isNoContent()); + mvc.perform(get("/api/v1/applications/" + other + "/resources").cookie(manager)).andExpect(status().isForbidden()); + mvc.perform(get("/api/v1/applications/" + other + "/clients").cookie(manager)).andExpect(status().isForbidden()); + String clientRequest = "{\"type\":\"PUBLIC\",\"settings\":{\"name\":\"SPA\",\"redirectUris\":[\"http://localhost:3000/cb\"],\"scopes\":[\"openid\"],\"grants\":[\"AUTHORIZATION_CODE\"]}}"; + mvc.perform(post("/api/v1/applications/" + managed + "/clients").with(csrf()).cookie(manager) + .contentType(MediaType.APPLICATION_JSON).content(clientRequest)).andExpect(status().isCreated()); + mvc.perform(post("/api/v1/applications/" + other + "/clients").with(csrf()).cookie(manager) + .contentType(MediaType.APPLICATION_JSON).content(clientRequest)).andExpect(status().isForbidden()); + mvc.perform(delete("/api/v1/resources/" + otherResource).with(csrf()).cookie(manager)).andExpect(status().isForbidden()); + mvc.perform(delete("/api/v1/applications/" + other + "/resource-tree").with(csrf()).cookie(manager)).andExpect(status().isForbidden()); + mvc.perform(post("/api/v1/applications").with(csrf()).cookie(manager).contentType(MediaType.APPLICATION_JSON) + .content("{\"code\":\"forbidden-app\",\"name\":\"Forbidden\"}")).andExpect(status().isForbidden()); + mvc.perform(delete("/api/v1/applications/" + managed).with(csrf()).cookie(manager)).andExpect(status().isForbidden()); + mvc.perform(put("/api/v1/applications/" + managed).with(csrf()).cookie(root).contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Renamed\"}")).andExpect(status().isOk()).andExpect(jsonPath("$.administratorId").value(user)); + mvc.perform(put("/api/v1/applications/" + managed).with(csrf()).cookie(root).contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Renamed\",\"administratorId\":\"\"}")).andExpect(status().isOk()); + mvc.perform(get("/api/v1/me/authorization").cookie(manager)).andExpect(jsonPath("$.roles", hasSize(0))); + mvc.perform(get("/api/v1/applications").cookie(manager)).andExpect(status().isForbidden()); + } + private String application(Cookie root, String code) throws Exception { return CatalogFlow.idOf(mvc.perform(post("/api/v1/applications").with(csrf()).cookie(root) diff --git a/core/grantforge-server/src/test/java/org/devlive/grantforge/server/role/RoleControllerTest.java b/core/grantforge-server/src/test/java/org/devlive/grantforge/server/role/RoleControllerTest.java index 3f275cb03..796369f11 100644 --- a/core/grantforge-server/src/test/java/org/devlive/grantforge/server/role/RoleControllerTest.java +++ b/core/grantforge-server/src/test/java/org/devlive/grantforge/server/role/RoleControllerTest.java @@ -87,7 +87,7 @@ void everyTenantHasItsSystemRolesAndManagesItsOwnRoles() throws Exception { Cookie root = login("root", PASSWORD); mvc.perform(get("/api/v1/roles").cookie(root)) - .andExpect(jsonPath("$[*].code").value(org.hamcrest.Matchers.contains("platform-admin", "tenant-admin"))); + .andExpect(jsonPath("$[*].code").value(org.hamcrest.Matchers.contains("application-admin", "platform-admin", "tenant-admin"))); Cookie boss = login("boss", PASSWORD + "!"); String body = mvc.perform(get("/api/v1/roles").cookie(boss)) diff --git a/core/grantforge-web/src/api/openapi.json b/core/grantforge-web/src/api/openapi.json index 1ffd8feca..9b6ebb96f 100644 --- a/core/grantforge-web/src/api/openapi.json +++ b/core/grantforge-web/src/api/openapi.json @@ -681,6 +681,9 @@ }, "ApplicationRequest" : { "properties" : { + "administratorId" : { + "type" : "string" + }, "code" : { "maxLength" : 64, "minLength" : 0, @@ -702,6 +705,9 @@ }, "ApplicationResponse" : { "properties" : { + "administratorId" : { + "type" : "string" + }, "builtin" : { "type" : "boolean" }, @@ -727,6 +733,9 @@ }, "ApplicationUpdateRequest" : { "properties" : { + "administratorId" : { + "type" : "string" + }, "description" : { "maxLength" : 500, "minLength" : 0, @@ -6212,6 +6221,33 @@ "x-permission" : "platform.resource.create" } }, + "/api/v1/applications/{id}/resource-tree/{rootId}/children" : { + "delete" : { + "operationId" : "clearChildren", + "parameters" : [ { + "in" : "path", + "name" : "id", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "in" : "path", + "name" : "rootId", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "204" : { + "description" : "No Content" + } + }, + "tags" : [ "resource-tree-import-controller" ], + "x-permission" : "platform.resource.delete" + } + }, "/api/v1/applications/{id}/resources" : { "get" : { "operationId" : "resources", diff --git a/core/grantforge-web/src/api/schema.d.ts b/core/grantforge-web/src/api/schema.d.ts index b735c9ea7..93d65b68f 100644 --- a/core/grantforge-web/src/api/schema.d.ts +++ b/core/grantforge-web/src/api/schema.d.ts @@ -420,6 +420,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/applications/{id}/resource-tree/{rootId}/children": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["clearChildren"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/applications/{id}/resources": { parameters: { query?: never; @@ -2722,11 +2738,13 @@ export interface components { reviewed: number; }; ApplicationRequest: { + administratorId?: string; code: string; description?: string; name: string; }; ApplicationResponse: { + administratorId?: string; builtin: boolean; code: string; description?: string; @@ -2736,6 +2754,7 @@ export interface components { resources: number; }; ApplicationUpdateRequest: { + administratorId?: string; description?: string; name: string; }; @@ -4912,6 +4931,27 @@ export interface operations { }; }; }; + clearChildren: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + rootId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; resources: { parameters: { query?: never; diff --git a/core/grantforge-web/src/components/UiSelect.test.ts b/core/grantforge-web/src/components/UiSelect.test.ts index c3d419f33..063ac9573 100644 --- a/core/grantforge-web/src/components/UiSelect.test.ts +++ b/core/grantforge-web/src/components/UiSelect.test.ts @@ -17,7 +17,7 @@ const options: Option[] = [ ] const wrappers: VueWrapper[] = [] -function render(props: Partial<{ modelValue: string; label: string; options: Option[]; disabled: boolean; required: boolean; error: string; hideLabel: boolean }> = {}) { +function render(props: Partial<{ modelValue: string; label: string; options: Option[]; disabled: boolean; required: boolean; error: string; hideLabel: boolean; searchable: boolean }> = {}) { const wrapper = mount(UiSelect, { attachTo: document.body, props: { modelValue: 'get', label: 'HTTP 方法', options, ...props } }) wrappers.push(wrapper) return wrapper @@ -208,4 +208,20 @@ describe('custom select interaction', () => { expect(combobox(wrapper).attributes('aria-expanded')).toBe('false') expect(wrapper.emitted('update:modelValue')).toBeUndefined() }) + + it('focuses search and supports keyboard navigation and selection', async () => { + const wrapper = render({ searchable: true }) + await open(wrapper) + const input = document.querySelector('input[type="search"]') + if (!input) throw new Error('Missing search input') + expect(document.activeElement).toBe(input) + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })) + await flushPromises() + expect(activeOption(wrapper)?.textContent).toContain('POST') + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })) + await flushPromises() + expect(wrapper.emitted('update:modelValue')).toEqual([['post']]) + expect(combobox(wrapper).attributes('aria-expanded')).toBe('false') + expect(document.activeElement).toBe(combobox(wrapper).element) + }) }) diff --git a/core/grantforge-web/src/components/UiSelect.vue b/core/grantforge-web/src/components/UiSelect.vue index 31eb42f4d..6ad126049 100644 --- a/core/grantforge-web/src/components/UiSelect.vue +++ b/core/grantforge-web/src/components/UiSelect.vue @@ -13,10 +13,16 @@ import UiTip from '@/components/UiTip.vue' interface SelectOption { value: string; label: string; description?: string; disabled?: boolean } const value = defineModel({ required: true }) -const { label, options, placeholder = '', disabled = false, required = false, error = '', compact = false, hideLabel = false } = defineProps<{ +const search = defineModel('search', { default: '' }) +const { label, options, placeholder = '', disabled = false, required = false, error = '', compact = false, hideLabel = false, + searchable = false, searchPlaceholder = '', searchLoading = false, searchEmpty = false, selectedLabel = '', + searchLoadingText = '', searchEmptyText = '' } = defineProps<{ label: string; options: SelectOption[]; placeholder?: string; disabled?: boolean; required?: boolean; error?: string; compact?: boolean; hideLabel?: boolean + searchable?: boolean; searchPlaceholder?: string; searchLoading?: boolean; searchEmpty?: boolean; selectedLabel?: string + searchLoadingText?: string; searchEmptyText?: string }>() const id = useId(), trigger = useTemplateRef('trigger'), panel = useTemplateRef('panel') +const searchInput = useTemplateRef('searchInput') const { t } = useI18n() const open = ref(false), active = ref(-1), portal = shallowRef(), position = ref({}) const selected = computed(() => options.find(option => option.value === value.value)) @@ -52,11 +58,13 @@ function show(index = options.findIndex(option => option.value === value.value & portal.value = trigger.value?.closest('dialog') || document.body active.value = enabled.value.includes(index) ? index : enabled.value[0] ?? -1 typed = ''; open.value = true; place(); scrollActive() + if (searchable) void nextTick(() => searchInput.value?.focus()) } function close(commit = false) { const option = options[active.value] if (open.value && commit && option && !option.disabled) value.value = option.value open.value = false; typed = '' + if (searchable) search.value = '' } function choose(index: number) { if (options[index]?.disabled) return @@ -102,6 +110,16 @@ function keydown(event: KeyboardEvent) { event.preventDefault(); if (!open.value) show(); typeahead(event.key) } } +function searchKeydown(event: KeyboardEvent) { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); move(event.key === 'ArrowDown' ? 1 : -1) + } else if (event.key === 'Enter') { + event.preventDefault() + if (active.value >= 0 && !searchLoading && !searchEmpty && (!search.value.trim() || options[active.value]?.value)) choose(active.value) + } else if (event.key === 'Escape') { + event.preventDefault(); event.stopPropagation(); close(); trigger.value?.focus() + } else if (event.key === 'Tab') close() +} function outside(event: PointerEvent) { if (event.target instanceof Node && !trigger.value?.contains(event.target) && !panel.value?.contains(event.target)) close(true) } @@ -156,35 +174,48 @@ onBeforeUnmount(cleanup) @keydown="keydown" @blur="blur" > - {{ selected?.label || placeholder || t('controls.selectPlaceholder') }} + {{ selected?.label || selectedLabel || placeholder || t('controls.selectPlaceholder') }}