Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand All @@ -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"));
}
Expand All @@ -81,7 +98,10 @@ public List<ApplicationView> list(long actorId)
return requireNonNull(transactions.execute(status -> {
Map<Long, Long> 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();
}));
Expand All @@ -98,6 +118,21 @@ public List<ApplicationView> 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),
Expand All @@ -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);
}
Expand All @@ -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())));
Expand All @@ -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));
}
Expand All @@ -173,7 +241,7 @@ public long registerConsole()

private Application write(long actorId, Supplier<Application> change)
{
access.requireEditor(actorId);
access.requirePlatformManager(actorId);
try {
return requireNonNull(transactions.execute(status -> change.get()));
}
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,8 @@ Set<Long> grantableResources(long accountId, long applicationId)
Instant now = clock.instant();
List<RoleView> 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();
Expand Down Expand Up @@ -440,7 +441,12 @@ private static List<Long> systemModules(List<RoleView> roles, CatalogView catalo
Set<String> 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<String> 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();
}

Expand Down
Loading
Loading