diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java new file mode 100644 index 0000000000..b2d6d3d12d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java @@ -0,0 +1,20 @@ +package stirling.software.proprietary.access.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import stirling.software.proprietary.access.service.DefaultTeamLeadLookup; +import stirling.software.proprietary.access.service.TeamLeadLookup; + +/** Access-layer bean wiring. */ +@Configuration +public class AccessConfig { + + /** No-op {@link TeamLeadLookup} unless another bean is defined. */ + @Bean + @ConditionalOnMissingBean(TeamLeadLookup.class) + TeamLeadLookup defaultTeamLeadLookup() { + return new DefaultTeamLeadLookup(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java new file mode 100644 index 0000000000..5b69c6878e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java @@ -0,0 +1,101 @@ +package stirling.software.proprietary.access.controller; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.access.model.AccessPermission; +import stirling.software.proprietary.access.model.PrincipalType; +import stirling.software.proprietary.access.model.ResourceGrant; +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.service.ResourceAccessService; +import stirling.software.proprietary.security.model.User; + +/** Admin endpoints to grant/revoke access to gated resources (the portal, integration configs). */ +@RestController +@RequestMapping("/api/v1/admin/access") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "Access Control", description = "Manage resource access grants (portal, integrations)") +public class ResourceGrantController { + + private final ResourceAccessService accessService; + + @GetMapping("/grants") + public ResponseEntity list( + @RequestParam ResourceType resourceType, + @RequestParam(required = false, defaultValue = "") String resourceId) { + List grants = accessService.listGrants(resourceType, resourceId); + return ResponseEntity.ok(grants.stream().map(this::toDto).toList()); + } + + @PostMapping("/grants") + public ResponseEntity create( + @RequestBody GrantRequest request, @AuthenticationPrincipal User admin) { + if (request.resourceType() == null + || request.principalType() == null + || request.principalId() == null) { + return ResponseEntity.badRequest() + .body( + Map.of( + "error", + "resourceType, principalType and principalId are required")); + } + AccessPermission permission = + request.permission() == null ? AccessPermission.USE : request.permission(); + // PORTAL is a singleton resource; its grants always target the whole type. + String resourceId = + request.resourceType() == ResourceType.PORTAL ? "" : request.resourceId(); + ResourceGrant grant = + accessService.grant( + request.resourceType(), + resourceId, + request.principalType(), + request.principalId(), + permission, + admin); + return ResponseEntity.ok(toDto(grant)); + } + + @DeleteMapping("/grants/{id}") + public ResponseEntity delete(@PathVariable Long id) { + accessService.revoke(id); + return ResponseEntity.ok(Map.of("message", "Grant revoked")); + } + + private Map toDto(ResourceGrant g) { + Map m = new HashMap<>(); + m.put("id", g.getId()); + m.put("resourceType", g.getResourceType()); + m.put("resourceId", g.getResourceId()); + m.put("principalType", g.getPrincipalType()); + m.put("principalId", g.getPrincipalId()); + m.put("permission", g.getPermission()); + m.put("createdAt", g.getCreatedAt()); + return m; + } + + /** Request body for creating a grant. */ + public record GrantRequest( + ResourceType resourceType, + String resourceId, + PrincipalType principalType, + Long principalId, + AccessPermission permission) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/AccessPermission.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/AccessPermission.java new file mode 100644 index 0000000000..2fd7e5e84e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/AccessPermission.java @@ -0,0 +1,7 @@ +package stirling.software.proprietary.access.model; + +/** Permission level a grant confers. MANAGE implies USE. */ +public enum AccessPermission { + USE, + MANAGE +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/DefaultAccessPolicy.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/DefaultAccessPolicy.java new file mode 100644 index 0000000000..9db62fe954 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/DefaultAccessPolicy.java @@ -0,0 +1,14 @@ +package stirling.software.proprietary.access.model; + +/** + * Fallback policy applied when no explicit {@link ResourceGrant} matches. Admins (org owners) + * always pass regardless of this policy. + */ +public enum DefaultAccessPolicy { + // Every authenticated user in the deployment (org) may use the resource. + ORG_ALL, + // Only org admins and team leaders. This is the default for the portal. + ADMINS_AND_TEAM_LEADS, + // Nobody but the owner, admins, and explicit grantees. + EXPLICIT_ONLY +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnedResource.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnedResource.java new file mode 100644 index 0000000000..1bca14a420 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnedResource.java @@ -0,0 +1,57 @@ +package stirling.software.proprietary.access.model; + +import jakarta.persistence.Column; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MappedSuperclass; + +import lombok.Getter; +import lombok.Setter; + +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; + +/** Base for a resource owned by a user, a team, or the server, with grant-based access. */ +@MappedSuperclass +@Getter +@Setter +public abstract class OwnedResource { + + @Enumerated(EnumType.STRING) + @Column(name = "scope", nullable = false, length = 32) + private OwnerScope scope; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_user_id") + private User ownerUser; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_team_id") + private Team ownerTeam; + + @Column(name = "enabled", nullable = false) + private boolean enabled = true; + + // Server resource that users cannot override with their own of the same kind. + @Column(name = "locked", nullable = false) + private boolean locked = false; + + // Who, besides owner/admin/grantees, may use this resource. + @Enumerated(EnumType.STRING) + @Column(name = "default_access", nullable = false, length = 32) + private DefaultAccessPolicy defaultAccess = DefaultAccessPolicy.EXPLICIT_ONLY; + + /** Subclass primary key. */ + public abstract Long getId(); + + public Long getOwnerUserId() { + return ownerUser != null ? ownerUser.getId() : null; + } + + public Long getOwnerTeamId() { + return ownerTeam != null ? ownerTeam.getId() : null; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnerScope.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnerScope.java new file mode 100644 index 0000000000..5c800a7630 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnerScope.java @@ -0,0 +1,8 @@ +package stirling.software.proprietary.access.model; + +/** Ownership scope of an {@link OwnedResource}: a single user, a team, or the whole server. */ +public enum OwnerScope { + USER, + TEAM, + SERVER +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalType.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalType.java new file mode 100644 index 0000000000..9019b32845 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalType.java @@ -0,0 +1,7 @@ +package stirling.software.proprietary.access.model; + +/** Who a {@link ResourceGrant} is granted to. Org-wide access is expressed via default policy. */ +public enum PrincipalType { + USER, + TEAM +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceGrant.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceGrant.java new file mode 100644 index 0000000000..266ef4ce15 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceGrant.java @@ -0,0 +1,86 @@ +package stirling.software.proprietary.access.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.proprietary.security.model.User; + +/** Grants a user or team access to a resource. Owner and admin access are implicit. */ +@Entity +@Table( + name = "resource_grants", + uniqueConstraints = + @UniqueConstraint( + name = "uk_resource_grant", + columnNames = { + "resource_type", + "resource_id", + "principal_type", + "principal_id", + "permission" + }), + indexes = { + @Index(name = "idx_resource_grants_lookup", columnList = "resource_type,resource_id"), + @Index( + name = "idx_resource_grants_principal", + columnList = "principal_type,principal_id") + }) +@NoArgsConstructor +@Getter +@Setter +public class ResourceGrant implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "resource_grant_id") + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "resource_type", nullable = false, length = 64) + private ResourceType resourceType; + + // Empty string (never null) for a whole-type grant such as the portal. + @Column(name = "resource_id", nullable = false, length = 255) + private String resourceId = ""; + + @Enumerated(EnumType.STRING) + @Column(name = "principal_type", nullable = false, length = 32) + private PrincipalType principalType; + + @Column(name = "principal_id", nullable = false) + private Long principalId; + + @Enumerated(EnumType.STRING) + @Column(name = "permission", nullable = false, length = 32) + private AccessPermission permission; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "granted_by_user_id") + private User grantedBy; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceType.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceType.java new file mode 100644 index 0000000000..9d0108db0f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceType.java @@ -0,0 +1,9 @@ +package stirling.software.proprietary.access.model; + +/** Types of resources whose access can be gated by {@link ResourceGrant}. */ +public enum ResourceType { + // The admin portal / processor (frontend/portal). Singleton resource (empty resourceId). + PORTAL, + // A stored S3/MCP/API integration configuration. + INTEGRATION_CONFIG +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/repository/ResourceGrantRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/repository/ResourceGrantRepository.java new file mode 100644 index 0000000000..76209c1abe --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/repository/ResourceGrantRepository.java @@ -0,0 +1,28 @@ +package stirling.software.proprietary.access.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.proprietary.access.model.PrincipalType; +import stirling.software.proprietary.access.model.ResourceGrant; +import stirling.software.proprietary.access.model.ResourceType; + +@Repository +public interface ResourceGrantRepository extends JpaRepository { + + List findByResourceTypeAndResourceId( + ResourceType resourceType, String resourceId); + + List findByResourceTypeAndPrincipalTypeAndPrincipalId( + ResourceType resourceType, PrincipalType principalType, Long principalId); + + void deleteByResourceTypeAndResourceId(ResourceType resourceType, String resourceId); + + boolean existsByResourceTypeAndResourceIdAndPrincipalTypeAndPrincipalId( + ResourceType resourceType, + String resourceId, + PrincipalType principalType, + Long principalId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java new file mode 100644 index 0000000000..5189b703a1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java @@ -0,0 +1,44 @@ +package stirling.software.proprietary.access.security; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.access.service.ResourceAccessService; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +/** {@code @PreAuthorize} bean for portal-access checks. Active in self-hosted and saas. */ +@Component("resourceAccess") +@RequiredArgsConstructor +public class ResourceAccessSecurity { + + private final ResourceAccessService accessService; + private final UserService userService; + + public boolean canUsePortal() { + User user = currentUser(); + return user != null && accessService.canAccessPortal(user); + } + + private User currentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + return null; + } + Object principal = auth.getPrincipal(); + if (principal instanceof User user) { + return user; + } + if (principal instanceof UserDetails userDetails) { + return userService.findByUsername(userDetails.getUsername()).orElse(null); + } + if (principal instanceof String username && !"anonymousUser".equals(username)) { + return userService.findByUsername(username).orElse(null); + } + return null; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/DefaultTeamLeadLookup.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/DefaultTeamLeadLookup.java new file mode 100644 index 0000000000..11b01b8d6b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/DefaultTeamLeadLookup.java @@ -0,0 +1,17 @@ +package stirling.software.proprietary.access.service; + +import stirling.software.proprietary.security.model.User; + +/** No-op {@link TeamLeadLookup}: always false. */ +public class DefaultTeamLeadLookup implements TeamLeadLookup { + + @Override + public boolean isAnyTeamLeader(User user) { + return false; + } + + @Override + public boolean isLeaderOfTeam(User user, Long teamId) { + return false; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java new file mode 100644 index 0000000000..8db87f2c0e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java @@ -0,0 +1,116 @@ +package stirling.software.proprietary.access.service; + +import java.util.Set; +import java.util.function.BooleanSupplier; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.model.OwnedResource; +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; + +/** Ownership and access checks for {@link OwnedResource}, backed by the resource-grant ACL. */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OwnershipService { + + private final ResourceAccessService accessService; + private final TeamLeadLookup teamLeadLookup; + private final TeamRepository teamRepository; + + /** Whether the user may use the resource. */ + public boolean canUse(ResourceType type, OwnedResource resource, User user) { + if (!resource.isEnabled()) { + return isAdmin(user) || isOwner(resource, user); + } + return accessService.canUseResource( + type, + String.valueOf(resource.getId()), + resource.getOwnerUserId(), + resource.getDefaultAccess(), + user); + } + + /** Whether the user may manage the resource. */ + public boolean canManage(ResourceType type, OwnedResource resource, User user) { + return accessService.canManageResource( + type, String.valueOf(resource.getId()), resource.getOwnerUserId(), user); + } + + /** + * Authorizes the scope and assigns ownership; {@code lockedOverrideBlocks} guards USER scope. + */ + public void assignOwnership( + OwnedResource resource, + OwnerScope scope, + Long teamId, + User user, + BooleanSupplier lockedOverrideBlocks) { + resource.setScope(scope); + switch (scope) { + case USER -> { + if (lockedOverrideBlocks.getAsBoolean() && !isAdmin(user)) { + throw forbidden( + "This is locked to the server configuration by an administrator"); + } + resource.setOwnerUser(user); + } + case SERVER -> { + if (!isAdmin(user)) { + throw forbidden("Only administrators can create server-owned resources"); + } + } + case TEAM -> { + if (teamId == null) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "ownerTeamId is required"); + } + Team team = + teamRepository + .findById(teamId) + .orElseThrow(() -> notFound("Team not found")); + if (!isAdmin(user) && !teamLeadLookup.isLeaderOfTeam(user, team.getId())) { + throw forbidden("Only admins or team leaders can create team-owned resources"); + } + resource.setOwnerTeam(team); + } + } + } + + /** Resource ids of the given type the user or their team holds a grant on. */ + public Set grantedResourceIds(ResourceType type, User user) { + return accessService.grantedResourceIds(type, user); + } + + public boolean isAdmin(User user) { + return user.getAuthorities().stream() + .anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority())); + } + + public boolean isOwner(OwnedResource resource, User user) { + if (resource.getOwnerUserId() != null && resource.getOwnerUserId().equals(user.getId())) { + return true; + } + // Team-owned: the lead of the owning team owns it. + return resource.getOwnerTeamId() != null + && teamLeadLookup.isLeaderOfTeam(user, resource.getOwnerTeamId()); + } + + private ResponseStatusException forbidden(String message) { + return new ResponseStatusException(HttpStatus.FORBIDDEN, message); + } + + private ResponseStatusException notFound(String message) { + return new ResponseStatusException(HttpStatus.NOT_FOUND, message); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java new file mode 100644 index 0000000000..ada4c95290 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java @@ -0,0 +1,188 @@ +package stirling.software.proprietary.access.service; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.model.AccessPermission; +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.PrincipalType; +import stirling.software.proprietary.access.model.ResourceGrant; +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; +import stirling.software.proprietary.security.model.User; + +/** Resolves access to gated resources: owner, then admin, then grant, then default policy. */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class ResourceAccessService { + + private final ResourceGrantRepository grantRepository; + private final TeamLeadLookup teamLeadLookup; + + @Value("${security.portal.defaultAccess:ADMINS_AND_TEAM_LEADS}") + private DefaultAccessPolicy portalDefaultPolicy; + + // ---- public checks ---- + + /** Whether the user may use the portal / processor. */ + public boolean canAccessPortal(User user) { + return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user); + } + + /** Whether the user may use a resource, falling back to its default policy. */ + public boolean canUseResource( + ResourceType type, + String resourceId, + Long ownerUserId, + DefaultAccessPolicy defaultPolicy, + User user) { + if (user == null) { + return false; + } + if (isOwner(ownerUserId, user) || isAdmin(user)) { + return true; + } + if (hasGrant(type, normalize(resourceId), user, AccessPermission.USE)) { + return true; + } + return matchesDefault(defaultPolicy, user); + } + + /** Whether the user may manage (edit/delete/share) a resource. No default-policy fallback. */ + public boolean canManageResource( + ResourceType type, String resourceId, Long ownerUserId, User user) { + if (user == null) { + return false; + } + if (isOwner(ownerUserId, user) || isAdmin(user)) { + return true; + } + return hasGrant(type, normalize(resourceId), user, AccessPermission.MANAGE); + } + + // ---- grant management ---- + + @Transactional + public ResourceGrant grant( + ResourceType type, + String resourceId, + PrincipalType principalType, + Long principalId, + AccessPermission permission, + User grantedBy) { + String rid = normalize(resourceId); + ResourceGrant grant = + grantRepository.findByResourceTypeAndResourceId(type, rid).stream() + .filter( + g -> + g.getPrincipalType() == principalType + && g.getPrincipalId().equals(principalId)) + .findFirst() + .orElseGet(ResourceGrant::new); + grant.setResourceType(type); + grant.setResourceId(rid); + grant.setPrincipalType(principalType); + grant.setPrincipalId(principalId); + grant.setPermission(permission); + if (grantedBy != null) { + grant.setGrantedBy(grantedBy); + } + return grantRepository.save(grant); + } + + @Transactional + public void revoke(Long grantId) { + grantRepository.deleteById(grantId); + } + + public List listGrants(ResourceType type, String resourceId) { + return grantRepository.findByResourceTypeAndResourceId(type, normalize(resourceId)); + } + + /** Resource ids of the given type that this user (or their team) holds any grant on. */ + public Set grantedResourceIds(ResourceType type, User user) { + if (user == null) { + return Set.of(); + } + Set ids = new HashSet<>(); + for (ResourceGrant g : + grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId( + type, PrincipalType.USER, user.getId())) { + ids.add(g.getResourceId()); + } + if (user.getTeam() != null) { + for (ResourceGrant g : + grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId( + type, PrincipalType.TEAM, user.getTeam().getId())) { + ids.add(g.getResourceId()); + } + } + return ids; + } + + // ---- internals ---- + + private boolean hasGrant( + ResourceType type, String resourceId, User user, AccessPermission required) { + Long teamId = user.getTeam() != null ? user.getTeam().getId() : null; + for (ResourceGrant g : grantRepository.findByResourceTypeAndResourceId(type, resourceId)) { + if (!permissionSatisfies(g.getPermission(), required)) { + continue; + } + if (g.getPrincipalType() == PrincipalType.USER + && g.getPrincipalId().equals(user.getId())) { + return true; + } + if (g.getPrincipalType() == PrincipalType.TEAM + && teamId != null + && g.getPrincipalId().equals(teamId)) { + return true; + } + } + return false; + } + + // MANAGE implies USE. + private boolean permissionSatisfies(AccessPermission held, AccessPermission required) { + if (required == AccessPermission.USE) { + return held == AccessPermission.USE || held == AccessPermission.MANAGE; + } + return held == AccessPermission.MANAGE; + } + + private boolean matchesDefault(DefaultAccessPolicy policy, User user) { + if (policy == null) { + return false; + } + return switch (policy) { + case ORG_ALL -> true; + // Admins already pass above; only team leads here. + case ADMINS_AND_TEAM_LEADS -> teamLeadLookup.isAnyTeamLeader(user); + case EXPLICIT_ONLY -> false; + }; + } + + private boolean isOwner(Long ownerUserId, User user) { + return ownerUserId != null && ownerUserId.equals(user.getId()); + } + + private boolean isAdmin(User user) { + return user.getAuthorities().stream() + .anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority())); + } + + private String normalize(String resourceId) { + return resourceId == null ? "" : resourceId; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java new file mode 100644 index 0000000000..917c180116 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java @@ -0,0 +1,151 @@ +package stirling.software.proprietary.access.service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Component; + +/** Masks, merges and sanitizes secret values in a config map, recursing into nested maps/lists. */ +@Component +public class SecretMasker { + + public static final String MASK = "********"; + + // Cap recursion so a pathologically nested payload cannot overflow the stack. + private static final int MAX_DEPTH = 32; + + private static final Set SENSITIVE_HINTS = + Set.of( + "secret", + "password", + "token", + "apikey", + "accesskey", + "credential", + "privatekey"); + + /** Replace sensitive values with the mask (recursively) for safe display. */ + public Map mask(Map config) { + return mask(config, 0); + } + + /** Drop sensitive blank/masked values from an incoming create payload. */ + public Map sanitize(Map config) { + return sanitize(config, 0); + } + + /** + * Merge an update over the stored map, keeping stored secrets where the incoming is redacted. + */ + public Map merge(Map stored, Map incoming) { + return merge(stored, incoming, 0); + } + + private Map mask(Map config, int depth) { + Map out = new LinkedHashMap<>(); + for (Map.Entry e : config.entrySet()) { + out.put(e.getKey(), maskValue(e.getKey(), e.getValue(), depth)); + } + return out; + } + + private Map sanitize(Map config, int depth) { + if (config == null) { + return new LinkedHashMap<>(); + } + Map out = new LinkedHashMap<>(); + for (Map.Entry e : config.entrySet()) { + if (isSensitive(e.getKey()) && isRedacted(e.getValue(), depth)) { + continue; + } + out.put( + e.getKey(), + e.getValue() instanceof Map m && depth < MAX_DEPTH + ? sanitize(castMap(m), depth + 1) + : e.getValue()); + } + return out; + } + + private Map merge( + Map stored, Map incoming, int depth) { + Map out = new LinkedHashMap<>(stored); + for (Map.Entry e : incoming.entrySet()) { + String key = e.getKey(); + Object value = e.getValue(); + if (isSensitive(key)) { + if (!isRedacted(value, depth)) { + out.put(key, value); // a real new secret replaces the stored one + } + continue; // redacted (blank / mask) -> keep stored + } + if (depth < MAX_DEPTH + && out.get(key) instanceof Map s + && value instanceof Map i) { + out.put(key, merge(castMap(s), castMap(i), depth + 1)); + } else { + out.put(key, value); + } + } + return out; + } + + // A sensitive key masks its whole value; recurse into non-sensitive containers. + private Object maskValue(String key, Object value, int depth) { + if (isSensitive(key)) { + if (value == null || (value instanceof String s && s.isBlank())) { + return value; + } + return MASK; + } + if (depth >= MAX_DEPTH) { + // Too deep to descend; mask containers rather than risk leaking an unmasked secret. + return value instanceof Map || value instanceof List ? MASK : value; + } + if (value instanceof Map m) { + return mask(castMap(m), depth + 1); + } + if (value instanceof List list) { + List out = new ArrayList<>(); + for (Object item : list) { + out.add(item instanceof Map m ? mask(castMap(m), depth + 1) : item); + } + return out; + } + return value; + } + + private boolean isSensitive(String key) { + String lower = key.toLowerCase(Locale.ROOT); + return SENSITIVE_HINTS.stream().anyMatch(lower::contains); + } + + /** Blank, the mask placeholder, or any structure that still contains the mask. */ + private boolean isRedacted(Object value, int depth) { + if (value == null) { + return true; + } + if (value instanceof String s) { + return s.isBlank() || MASK.equals(s); + } + if (depth >= MAX_DEPTH) { + return false; + } + if (value instanceof Map m) { + return m.values().stream().anyMatch(v -> isRedacted(v, depth + 1)); + } + if (value instanceof List list) { + return list.stream().anyMatch(v -> isRedacted(v, depth + 1)); + } + return false; + } + + @SuppressWarnings("unchecked") + private Map castMap(Map map) { + return (Map) map; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/TeamLeadLookup.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/TeamLeadLookup.java new file mode 100644 index 0000000000..9d05b05de5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/TeamLeadLookup.java @@ -0,0 +1,13 @@ +package stirling.software.proprietary.access.service; + +import stirling.software.proprietary.security.model.User; + +/** Resolves whether a user leads a team. */ +public interface TeamLeadLookup { + + /** Whether the user leads at least one team. */ + boolean isAnyTeamLeader(User user); + + /** Whether the user leads the given team. */ + boolean isLeaderOfTeam(User user, Long teamId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java new file mode 100644 index 0000000000..220c4e8d65 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java @@ -0,0 +1,81 @@ +package stirling.software.proprietary.integration.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.integration.dto.IntegrationConfigRequest; +import stirling.software.proprietary.integration.dto.IntegrationConfigResponse; +import stirling.software.proprietary.integration.service.IntegrationConfigService; +import stirling.software.proprietary.security.model.User; + +/** CRUD for S3/MCP/API integration configs. Secrets are never returned. */ +@RestController +@RequestMapping("/api/v1/integrations") +@RequiredArgsConstructor +@PreAuthorize("isAuthenticated()") +@Tag(name = "Integrations", description = "Manage S3/MCP/API integration configurations") +public class IntegrationConfigController { + + private final IntegrationConfigService service; + + @GetMapping + public ResponseEntity> list( + @AuthenticationPrincipal User user) { + requireUser(user); + return ResponseEntity.ok( + service.listVisible(user).stream().map(c -> service.toResponse(c, user)).toList()); + } + + @PostMapping + public ResponseEntity create( + @RequestBody IntegrationConfigRequest request, @AuthenticationPrincipal User user) { + requireUser(user); + return ResponseEntity.ok(service.toResponse(service.create(request, user), user)); + } + + @GetMapping("/{id}") + public ResponseEntity get( + @PathVariable Long id, @AuthenticationPrincipal User user) { + requireUser(user); + return ResponseEntity.ok(service.toResponse(service.getForUse(id, user), user)); + } + + @PutMapping("/{id}") + public ResponseEntity update( + @PathVariable Long id, + @RequestBody IntegrationConfigRequest request, + @AuthenticationPrincipal User user) { + requireUser(user); + return ResponseEntity.ok(service.toResponse(service.update(id, request, user), user)); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id, @AuthenticationPrincipal User user) { + requireUser(user); + service.delete(id, user); + return ResponseEntity.noContent().build(); + } + + private void requireUser(User user) { + if (user == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required"); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java new file mode 100644 index 0000000000..1fff2dd321 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java @@ -0,0 +1,135 @@ +package stirling.software.proprietary.integration.crypto; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.configuration.InstallationPathConfig; + +/** + * AES-256-GCM for stored credentials. Key from property, env var, or an auto-generated key file. + */ +@Component +@Slf4j +public class CredentialEncryption { + + private static final String ALGORITHM = "AES"; + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int GCM_TAG_BITS = 128; + private static final int IV_BYTES = 12; + private static final String KEY_FILE = "credential-encryption.key"; + private static final SecureRandom RANDOM = new SecureRandom(); + + private static volatile SecretKey key; + + private final String configuredKey; + + public CredentialEncryption( + @Value("${stirling.security.credentialEncryptionKey:}") String configuredKey) { + this.configuredKey = configuredKey; + } + + @PostConstruct + void init() { + key = resolveKey(); + log.info("Credential encryption initialised (AES-256-GCM)"); + } + + private SecretKey resolveKey() { + String configured = configuredKey; + if (configured == null || configured.isBlank()) { + configured = System.getenv("STIRLING_CREDENTIAL_ENCRYPTION_KEY"); + } + if (configured != null && !configured.isBlank()) { + return new SecretKeySpec(Base64.getDecoder().decode(configured.trim()), ALGORITHM); + } + return loadOrCreateKeyFile(); + } + + private SecretKey loadOrCreateKeyFile() { + Path path = Path.of(InstallationPathConfig.getConfigPath(), KEY_FILE); + try { + if (Files.exists(path)) { + String encoded = Files.readString(path).trim(); + return new SecretKeySpec(Base64.getDecoder().decode(encoded), ALGORITHM); + } + KeyGenerator generator = KeyGenerator.getInstance(ALGORITHM); + generator.init(256); + SecretKey generated = generator.generateKey(); + Files.createDirectories(path.getParent()); + Files.writeString(path, Base64.getEncoder().encodeToString(generated.getEncoded())); + log.warn( + "Generated a new credential encryption key at {}. Back this file up: losing it" + + " makes stored integration secrets unrecoverable.", + path); + return generated; + } catch (Exception e) { + throw new IllegalStateException("Unable to initialise credential encryption key", e); + } + } + + public static String encrypt(String plaintext) { + if (plaintext == null) { + return null; + } + try { + byte[] iv = new byte[IV_BYTES]; + RANDOM.nextBytes(iv); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_BITS, iv)); + byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + byte[] combined = new byte[iv.length + ciphertext.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length); + return Base64.getEncoder().encodeToString(combined); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to encrypt credential", e); + } + } + + public static String decrypt(String stored) { + if (stored == null) { + return null; + } + try { + byte[] combined = Base64.getDecoder().decode(stored); + byte[] iv = Arrays.copyOfRange(combined, 0, IV_BYTES); + byte[] ciphertext = Arrays.copyOfRange(combined, IV_BYTES, combined.length); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_BITS, iv)); + return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to decrypt credential", e); + } + } + + private static SecretKey requireKey() { + SecretKey current = key; + if (current == null) { + throw new IllegalStateException("Credential encryption not initialised"); + } + return current; + } + + /** For tests. */ + static void initialiseForTesting(SecretKey testKey) { + key = testKey; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/EncryptedStringConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/EncryptedStringConverter.java new file mode 100644 index 0000000000..2696d69f90 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/EncryptedStringConverter.java @@ -0,0 +1,19 @@ +package stirling.software.proprietary.integration.crypto; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** Transparently encrypts/decrypts a string column at rest via {@link CredentialEncryption}. */ +@Converter +public class EncryptedStringConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(String attribute) { + return CredentialEncryption.encrypt(attribute); + } + + @Override + public String convertToEntityAttribute(String dbData) { + return CredentialEncryption.decrypt(dbData); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/dto/IntegrationConfigRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/dto/IntegrationConfigRequest.java new file mode 100644 index 0000000000..279a5bf4b2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/dto/IntegrationConfigRequest.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.integration.dto; + +import java.util.Map; + +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.integration.model.IntegrationType; + +/** Create/update payload for an integration config. Sensitive config values left blank are kept. */ +public record IntegrationConfigRequest( + IntegrationType integrationType, + String name, + OwnerScope scope, + Long ownerTeamId, + Boolean enabled, + Boolean locked, + DefaultAccessPolicy defaultAccess, + Map config) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/dto/IntegrationConfigResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/dto/IntegrationConfigResponse.java new file mode 100644 index 0000000000..b1b3303571 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/dto/IntegrationConfigResponse.java @@ -0,0 +1,24 @@ +package stirling.software.proprietary.integration.dto; + +import java.time.LocalDateTime; +import java.util.Map; + +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.integration.model.IntegrationType; + +/** Integration config view. Sensitive config values are masked. */ +public record IntegrationConfigResponse( + Long id, + IntegrationType integrationType, + String name, + OwnerScope scope, + Long ownerUserId, + Long ownerTeamId, + boolean enabled, + boolean locked, + DefaultAccessPolicy defaultAccess, + Map config, + boolean canManage, + LocalDateTime createdAt, + LocalDateTime updatedAt) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationConfig.java new file mode 100644 index 0000000000..350ad7115d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationConfig.java @@ -0,0 +1,67 @@ +package stirling.software.proprietary.integration.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.proprietary.access.model.OwnedResource; +import stirling.software.proprietary.integration.crypto.EncryptedStringConverter; + +/** A named S3/MCP/API integration config; scope and ownership live on {@link OwnedResource}. */ +@Entity +@Table( + name = "integration_configs", + indexes = { + @Index(name = "idx_integration_configs_owner", columnList = "owner_user_id"), + @Index(name = "idx_integration_configs_type", columnList = "integration_type"), + @Index(name = "idx_integration_configs_scope", columnList = "scope") + }) +@NoArgsConstructor +@Getter +@Setter +public class IntegrationConfig extends OwnedResource implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "integration_config_id") + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "integration_type", nullable = false, length = 32) + private IntegrationType integrationType; + + @Column(name = "name", nullable = false) + private String name; + + // Type-specific fields as an AES-GCM encrypted JSON blob. + @Convert(converter = EncryptedStringConverter.class) + @Column(name = "config_encrypted", columnDefinition = "text") + private String config; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationType.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationType.java new file mode 100644 index 0000000000..1e5bc0c7d4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationType.java @@ -0,0 +1,8 @@ +package stirling.software.proprietary.integration.model; + +/** Kind of external integration a stored config describes. */ +public enum IntegrationType { + S3, + MCP, + API +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java new file mode 100644 index 0000000000..6efb45d745 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java @@ -0,0 +1,21 @@ +package stirling.software.proprietary.integration.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; + +@Repository +public interface IntegrationConfigRepository extends JpaRepository { + + List findByOwnerUser(User ownerUser); + + List findByOwnerTeam(Team ownerTeam); + + List findByScope(OwnerScope scope); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java new file mode 100644 index 0000000000..b384cc48e6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java @@ -0,0 +1,226 @@ +package stirling.software.proprietary.integration.service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.service.OwnershipService; +import stirling.software.proprietary.access.service.SecretMasker; +import stirling.software.proprietary.integration.dto.IntegrationConfigRequest; +import stirling.software.proprietary.integration.dto.IntegrationConfigResponse; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.security.model.User; + +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +/** CRUD for {@link IntegrationConfig}; delegates ownership and masking to shared services. */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class IntegrationConfigService { + + private static final ResourceType TYPE = ResourceType.INTEGRATION_CONFIG; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final IntegrationConfigRepository repository; + private final OwnershipService ownership; + private final SecretMasker secretMasker; + + // ---- commands ---- + + @Transactional + public IntegrationConfig create(IntegrationConfigRequest request, User currentUser) { + OwnerScope scope = request.scope() == null ? OwnerScope.USER : request.scope(); + IntegrationConfig cfg = new IntegrationConfig(); + cfg.setIntegrationType(require(request.integrationType(), "integrationType")); + cfg.setName(require(request.name(), "name")); + cfg.setEnabled(request.enabled() == null || request.enabled()); + cfg.setLocked(request.locked() != null && request.locked()); + cfg.setDefaultAccess( + request.defaultAccess() == null + ? DefaultAccessPolicy.EXPLICIT_ONLY + : request.defaultAccess()); + + ownership.assignOwnership( + cfg, + scope, + request.ownerTeamId(), + currentUser, + () -> lockedServerExists(cfg.getIntegrationType())); + cfg.setConfig(writeJson(secretMasker.sanitize(request.config()))); + return repository.save(cfg); + } + + @Transactional + public IntegrationConfig update(Long id, IntegrationConfigRequest request, User currentUser) { + IntegrationConfig cfg = load(id); + if (!ownership.canManage(TYPE, cfg, currentUser)) { + throw forbidden("You cannot manage this integration"); + } + if (cfg.isLocked() && !ownership.isAdmin(currentUser)) { + throw forbidden("This integration is locked by an administrator"); + } + if (request.name() != null) { + cfg.setName(request.name()); + } + if (request.enabled() != null) { + cfg.setEnabled(request.enabled()); + } + if (request.locked() != null && request.locked() != cfg.isLocked()) { + if (!ownership.isAdmin(currentUser)) { + throw forbidden("Only administrators can change the locked flag"); + } + cfg.setLocked(request.locked()); + } + if (request.defaultAccess() != null) { + cfg.setDefaultAccess(request.defaultAccess()); + } + if (request.config() != null) { + cfg.setConfig( + writeJson(secretMasker.merge(readJson(cfg.getConfig()), request.config()))); + } + return repository.save(cfg); + } + + @Transactional + public void delete(Long id, User currentUser) { + IntegrationConfig cfg = load(id); + if (!ownership.canManage(TYPE, cfg, currentUser)) { + throw forbidden("You cannot manage this integration"); + } + repository.delete(cfg); + } + + // ---- queries ---- + + public IntegrationConfig getForUse(Long id, User currentUser) { + IntegrationConfig cfg = load(id); + if (!ownership.canUse(TYPE, cfg, currentUser)) { + throw forbidden("You cannot access this integration"); + } + return cfg; + } + + /** + * All configs the user owns, plus server/team configs and grant-shared configs they may use. + */ + public List listVisible(User currentUser) { + Map byId = new LinkedHashMap<>(); + for (IntegrationConfig c : repository.findByOwnerUser(currentUser)) { + byId.put(c.getId(), c); + } + for (IntegrationConfig c : repository.findByScope(OwnerScope.SERVER)) { + if (ownership.canUse(TYPE, c, currentUser)) { + byId.put(c.getId(), c); + } + } + if (currentUser.getTeam() != null) { + for (IntegrationConfig c : repository.findByOwnerTeam(currentUser.getTeam())) { + if (ownership.canUse(TYPE, c, currentUser)) { + byId.put(c.getId(), c); + } + } + } + for (String rid : ownership.grantedResourceIds(TYPE, currentUser)) { + if (rid == null || rid.isBlank()) { + continue; + } + Long cid; + try { + cid = Long.valueOf(rid); + } catch (NumberFormatException e) { + continue; + } + if (byId.containsKey(cid)) { + continue; + } + repository + .findById(cid) + .filter(c -> ownership.canUse(TYPE, c, currentUser)) + .ifPresent(c -> byId.put(c.getId(), c)); + } + return new ArrayList<>(byId.values()); + } + + public IntegrationConfigResponse toResponse(IntegrationConfig cfg, User user) { + return new IntegrationConfigResponse( + cfg.getId(), + cfg.getIntegrationType(), + cfg.getName(), + cfg.getScope(), + cfg.getOwnerUserId(), + cfg.getOwnerTeamId(), + cfg.isEnabled(), + cfg.isLocked(), + cfg.getDefaultAccess(), + secretMasker.mask(readJson(cfg.getConfig())), + ownership.canManage(TYPE, cfg, user), + cfg.getCreatedAt(), + cfg.getUpdatedAt()); + } + + // ---- integration-specific glue ---- + + /** A non-admin can't create a personal config of a type an admin has locked at server scope. */ + private boolean lockedServerExists(IntegrationType type) { + return repository.findByScope(OwnerScope.SERVER).stream() + .anyMatch(c -> c.getIntegrationType() == type && c.isLocked()); + } + + private IntegrationConfig load(Long id) { + return repository + .findById(id) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "Integration not found")); + } + + private String writeJson(Map config) { + try { + return OBJECT_MAPPER.writeValueAsString(config == null ? Map.of() : config); + } catch (Exception e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid config payload"); + } + } + + private Map readJson(String json) { + if (json == null || json.isBlank()) { + return new LinkedHashMap<>(); + } + try { + return OBJECT_MAPPER.readValue( + json, new TypeReference>() {}); + } catch (Exception e) { + log.error("Failed to parse integration config JSON", e); + return new LinkedHashMap<>(); + } + } + + private T require(T value, String field) { + if (value == null || (value instanceof String s && s.isBlank())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + " is required"); + } + return value; + } + + private ResponseStatusException forbidden(String message) { + return new ResponseStatusException(HttpStatus.FORBIDDEN, message); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index ee449b31f2..c53d835ca5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -33,8 +33,10 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.storage.repository", "stirling.software.proprietary.workflow.repository", "stirling.software.proprietary.policy.store", + "stirling.software.proprietary.policy.source", "stirling.software.proprietary.accountlink", - "stirling.software.proprietary.policy.source" + "stirling.software.proprietary.access.repository", + "stirling.software.proprietary.integration.repository" }) @EntityScan({ "stirling.software.proprietary.security.model", @@ -42,8 +44,10 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.storage.model", "stirling.software.proprietary.workflow.model", "stirling.software.proprietary.policy.store", + "stirling.software.proprietary.policy.source", "stirling.software.proprietary.accountlink", - "stirling.software.proprietary.policy.source" + "stirling.software.proprietary.access.model", + "stirling.software.proprietary.integration.model" }) public class DatabaseConfig { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index 490dea2556..dcea7807b1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -28,6 +28,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.constants.JwtConstants; import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.access.service.ResourceAccessService; import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; @@ -64,6 +65,7 @@ public class AuthController { private final ApplicationProperties.Security securityProperties; private final ApplicationProperties applicationProperties; private final AiUserDataService aiUserDataService; + private final ResourceAccessService resourceAccessService; /** * Login endpoint - replaces Supabase signInWithPassword @@ -628,6 +630,7 @@ public class AuthController { userMap.put("username", user.getUsername()); userMap.put("role", user.getRolesAsString()); userMap.put("enabled", user.isEnabled()); + userMap.put("portalAccess", resourceAccessService.canAccessPortal(user)); userMap.put( "authenticationType", user.getAuthenticationType()); // Expose authentication type for SSO detection diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/OwnershipServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/OwnershipServiceTest.java new file mode 100644 index 0000000000..3f70058d0b --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/OwnershipServiceTest.java @@ -0,0 +1,174 @@ +package stirling.software.proprietary.access.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.proprietary.access.model.OwnedResource; +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; + +@ExtendWith(MockitoExtension.class) +class OwnershipServiceTest { + + private static final ResourceType TYPE = ResourceType.INTEGRATION_CONFIG; + + @Mock private ResourceAccessService accessService; + @Mock private TeamLeadLookup teamLeadLookup; + @Mock private TeamRepository teamRepository; + + @InjectMocks private OwnershipService ownership; + + /** Minimal concrete OwnedResource for exercising the base behaviour. */ + static class TestResource extends OwnedResource { + private final Long id; + + TestResource(Long id) { + this.id = id; + } + + @Override + public Long getId() { + return id; + } + } + + // ---- scope-based create authorization ---- + + @Test + void userScopeSetsOwner() { + User user = user(7); + TestResource r = new TestResource(1L); + + ownership.assignOwnership(r, OwnerScope.USER, null, user, () -> false); + + assertThat(r.getScope()).isEqualTo(OwnerScope.USER); + assertThat(r.getOwnerUser()).isSameAs(user); + } + + @Test + void userScopeBlockedByLockedServerOverride() { + assertForbidden( + () -> + ownership.assignOwnership( + new TestResource(1L), OwnerScope.USER, null, user(7), () -> true)); + } + + @Test + void adminMayCreateServerScope() { + TestResource r = new TestResource(1L); + ownership.assignOwnership(r, OwnerScope.SERVER, null, admin(1), () -> false); + assertThat(r.getOwnerUser()).isNull(); + } + + @Test + void nonAdminCannotCreateServerScope() { + assertForbidden( + () -> + ownership.assignOwnership( + new TestResource(1L), + OwnerScope.SERVER, + null, + user(7), + () -> false)); + } + + @Test + void teamLeaderMayCreateTeamScope() { + Team team = new Team(); + team.setId(5L); + User user = user(7); + when(teamRepository.findById(5L)).thenReturn(Optional.of(team)); + when(teamLeadLookup.isLeaderOfTeam(user, 5L)).thenReturn(true); + + TestResource r = new TestResource(1L); + ownership.assignOwnership(r, OwnerScope.TEAM, 5L, user, () -> false); + + assertThat(r.getOwnerTeam()).isSameAs(team); + } + + @Test + void nonLeaderCannotCreateTeamScope() { + Team team = new Team(); + team.setId(5L); + when(teamRepository.findById(5L)).thenReturn(Optional.of(team)); + + assertForbidden( + () -> + ownership.assignOwnership( + new TestResource(1L), OwnerScope.TEAM, 5L, user(7), () -> false)); + } + + // ---- use / manage ---- + + @Test + void enabledResourceUseDelegatesToTheAcl() { + TestResource r = new TestResource(1L); + User user = user(7); + when(accessService.canUseResource(any(), any(), any(), any(), any())).thenReturn(true); + + assertThat(ownership.canUse(TYPE, r, user)).isTrue(); + } + + @Test + void disabledResourceUsableOnlyByOwnerOrAdmin() { + TestResource r = new TestResource(1L); + r.setEnabled(false); + r.setOwnerUser(user(7)); + + assertThat(ownership.canUse(TYPE, r, user(7))).isTrue(); // owner + assertThat(ownership.canUse(TYPE, r, user(8))).isFalse(); // someone else + assertThat(ownership.canUse(TYPE, r, admin(2))).isTrue(); // admin + } + + @Test + void disabledTeamResourceUsableByLeaderOfOwningTeam() { + Team team = new Team(); + team.setId(5L); + TestResource r = new TestResource(1L); + r.setEnabled(false); + r.setOwnerTeam(team); + User leader = user(7); + when(teamLeadLookup.isLeaderOfTeam(leader, 5L)).thenReturn(true); + + assertThat(ownership.canUse(TYPE, r, leader)).isTrue(); // lead of the owning team + assertThat(ownership.canUse(TYPE, r, user(8))).isFalse(); // not a lead + } + + // ---- helpers ---- + + private void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable call) { + assertThatThrownBy(call) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + private User user(long id) { + User u = new User(); + u.setId(id); + u.setUsername("user" + id); + return u; + } + + private User admin(long id) { + User u = user(id); + new Authority("ROLE_ADMIN", u); + return u; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java new file mode 100644 index 0000000000..230c207eb0 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java @@ -0,0 +1,217 @@ +package stirling.software.proprietary.access.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.proprietary.access.model.AccessPermission; +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.PrincipalType; +import stirling.software.proprietary.access.model.ResourceGrant; +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.User; + +@ExtendWith(MockitoExtension.class) +class ResourceAccessServiceTest { + + private static final ResourceType TYPE = ResourceType.INTEGRATION_CONFIG; + private static final String RID = "42"; + + @Mock private ResourceGrantRepository grantRepository; + @Mock private TeamLeadLookup teamLeadLookup; + + @InjectMocks private ResourceAccessService service; + + @BeforeEach + void setPortalDefault() throws Exception { + Field f = ResourceAccessService.class.getDeclaredField("portalDefaultPolicy"); + f.setAccessible(true); + f.set(service, DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS); + } + + // ---- owner / admin short-circuits ---- + + @Test + void adminMayUseEvenWithExplicitOnlyAndNoGrants() { + assertThat( + service.canUseResource( + TYPE, RID, null, DefaultAccessPolicy.EXPLICIT_ONLY, admin(1))) + .isTrue(); + } + + @Test + void ownerMayUseEvenWithExplicitOnly() { + assertThat( + service.canUseResource( + TYPE, RID, 5L, DefaultAccessPolicy.EXPLICIT_ONLY, user(5))) + .isTrue(); + } + + @Test + void nullUserIsAlwaysDenied() { + assertThat(service.canUseResource(TYPE, RID, 5L, DefaultAccessPolicy.ORG_ALL, null)) + .isFalse(); + assertThat(service.canManageResource(TYPE, RID, 5L, null)).isFalse(); + } + + // ---- explicit grants ---- + + @Test + void explicitUserGrantAllowsUse() { + stubGrants(grant(PrincipalType.USER, 5L, AccessPermission.USE)); + assertThat( + service.canUseResource( + TYPE, RID, null, DefaultAccessPolicy.EXPLICIT_ONLY, user(5))) + .isTrue(); + } + + @Test + void teamGrantAllowsUseForTeamMember() { + stubGrants(grant(PrincipalType.TEAM, 7L, AccessPermission.USE)); + assertThat( + service.canUseResource( + TYPE, + RID, + null, + DefaultAccessPolicy.EXPLICIT_ONLY, + userInTeam(5, 7))) + .isTrue(); + } + + @Test + void teamGrantDoesNotLeakToOtherTeams() { + stubGrants(grant(PrincipalType.TEAM, 7L, AccessPermission.USE)); + assertThat( + service.canUseResource( + TYPE, + RID, + null, + DefaultAccessPolicy.EXPLICIT_ONLY, + userInTeam(5, 99))) + .isFalse(); + } + + @Test + void manageGrantImpliesUse() { + stubGrants(grant(PrincipalType.USER, 5L, AccessPermission.MANAGE)); + assertThat( + service.canUseResource( + TYPE, RID, null, DefaultAccessPolicy.EXPLICIT_ONLY, user(5))) + .isTrue(); + } + + @Test + void useGrantDoesNotImplyManage() { + stubGrants(grant(PrincipalType.USER, 5L, AccessPermission.USE)); + assertThat(service.canManageResource(TYPE, RID, null, user(5))).isFalse(); + } + + @Test + void manageGrantAllowsManage() { + stubGrants(grant(PrincipalType.USER, 5L, AccessPermission.MANAGE)); + assertThat(service.canManageResource(TYPE, RID, null, user(5))).isTrue(); + } + + // ---- default policies ---- + + @Test + void orgAllDefaultAllowsAnyUser() { + stubGrants(); + assertThat(service.canUseResource(TYPE, RID, null, DefaultAccessPolicy.ORG_ALL, user(5))) + .isTrue(); + } + + @Test + void explicitOnlyDefaultDeniesUngrantedUser() { + stubGrants(); + assertThat( + service.canUseResource( + TYPE, RID, null, DefaultAccessPolicy.EXPLICIT_ONLY, user(5))) + .isFalse(); + } + + @Test + void teamLeadDefaultAllowsLeaderButNotRegularUser() { + stubGrants(); + User leader = user(5); + when(teamLeadLookup.isAnyTeamLeader(leader)).thenReturn(true); + assertThat( + service.canUseResource( + TYPE, RID, null, DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS, leader)) + .isTrue(); + + stubGrants(); + assertThat( + service.canUseResource( + TYPE, + RID, + null, + DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS, + user(6))) + .isFalse(); + } + + // ---- portal convenience (default policy ADMINS_AND_TEAM_LEADS) ---- + + @Test + void portalAccessibleByAdmin() { + assertThat(service.canAccessPortal(admin(1))).isTrue(); + } + + @Test + void portalDeniedToRegularUser() { + when(grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) + .thenReturn(List.of()); + assertThat(service.canAccessPortal(user(5))).isFalse(); + } + + // ---- helpers ---- + + private void stubGrants(ResourceGrant... grants) { + when(grantRepository.findByResourceTypeAndResourceId(TYPE, RID)) + .thenReturn(List.of(grants)); + } + + private ResourceGrant grant(PrincipalType type, long principalId, AccessPermission permission) { + ResourceGrant g = new ResourceGrant(); + g.setResourceType(TYPE); + g.setResourceId(RID); + g.setPrincipalType(type); + g.setPrincipalId(principalId); + g.setPermission(permission); + return g; + } + + private User user(long id) { + User u = new User(); + u.setId(id); + u.setUsername("user" + id); + return u; + } + + private User admin(long id) { + User u = user(id); + new Authority("ROLE_ADMIN", u); + return u; + } + + private User userInTeam(long id, long teamId) { + User u = user(id); + Team team = new Team(); + team.setId(teamId); + u.setTeam(team); + return u; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java new file mode 100644 index 0000000000..ea5597cb12 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java @@ -0,0 +1,80 @@ +package stirling.software.proprietary.access.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class SecretMaskerTest { + + private final SecretMasker masker = new SecretMasker(); + + @Test + void maskHidesFlatAndNestedSecretsButKeepsPlainFields() { + Map nested = new LinkedHashMap<>(); + nested.put("host", "h.example"); + nested.put("secretKey", "sk-real"); + Map config = new LinkedHashMap<>(); + config.put("bucket", "acme"); + config.put("accessKey", "AKIA123"); + config.put("connection", nested); // non-sensitive parent, sensitive child + + Map masked = masker.mask(config); + + assertThat(masked.get("bucket")).isEqualTo("acme"); + assertThat(masked.get("accessKey")).isEqualTo(SecretMasker.MASK); + @SuppressWarnings("unchecked") + Map conn = (Map) masked.get("connection"); + assertThat(conn.get("host")).isEqualTo("h.example"); + assertThat(conn.get("secretKey")).isEqualTo(SecretMasker.MASK); + } + + @Test + void mergeKeepsStoredSecretWhenIncomingIsMasked() { + Map stored = Map.of("bucket", "old", "secretKey", "REAL"); + Map incoming = Map.of("bucket", "new", "secretKey", SecretMasker.MASK); + + Map merged = masker.merge(stored, incoming); + + assertThat(merged.get("bucket")).isEqualTo("new"); // non-secret updated + assertThat(merged.get("secretKey")).isEqualTo("REAL"); // secret retained + } + + @Test + void mergeAcceptsARealNewSecret() { + Map merged = + masker.merge(Map.of("secretKey", "OLD"), Map.of("secretKey", "NEW")); + assertThat(merged.get("secretKey")).isEqualTo("NEW"); + } + + @Test + void sanitizeDropsBlankSecretsOnCreate() { + Map incoming = new LinkedHashMap<>(); + incoming.put("bucket", "b"); + incoming.put("secretKey", ""); + + Map clean = masker.sanitize(incoming); + + assertThat(clean).containsEntry("bucket", "b").doesNotContainKey("secretKey"); + } + + @Test + void deeplyNestedInputIsBoundedNotOverflowing() { + // Build a structure far deeper than the recursion cap. + Map root = new LinkedHashMap<>(); + Map cur = root; + for (int i = 0; i < 2000; i++) { + Map next = new LinkedHashMap<>(); + cur.put("child", next); + cur = next; + } + cur.put("secretKey", "deep"); + + // Must return (bounded recursion), not throw StackOverflowError. + assertThat(masker.mask(root)).isNotNull(); + assertThat(masker.sanitize(root)).isNotNull(); + assertThat(masker.merge(root, root)).isNotNull(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/CredentialEncryptionTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/CredentialEncryptionTest.java new file mode 100644 index 0000000000..e6e5d356bf --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/CredentialEncryptionTest.java @@ -0,0 +1,64 @@ +package stirling.software.proprietary.integration.crypto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Base64; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class CredentialEncryptionTest { + + @BeforeAll + static void initKey() throws Exception { + KeyGenerator generator = KeyGenerator.getInstance("AES"); + generator.init(256); + SecretKey key = generator.generateKey(); + CredentialEncryption.initialiseForTesting(key); + } + + @Test + void roundTripRecoversPlaintext() { + String plaintext = "s3-secret-key-ABC123/+=value"; + String encrypted = CredentialEncryption.encrypt(plaintext); + + assertThat(encrypted).isNotNull().isNotEqualTo(plaintext); + assertThat(CredentialEncryption.decrypt(encrypted)).isEqualTo(plaintext); + } + + @Test + void sameInputProducesDifferentCiphertext() { + String plaintext = "repeated-secret"; + + // Random IV per encryption => ciphertext must differ, but both decrypt back. + String first = CredentialEncryption.encrypt(plaintext); + String second = CredentialEncryption.encrypt(plaintext); + + assertThat(first).isNotEqualTo(second); + assertThat(CredentialEncryption.decrypt(first)).isEqualTo(plaintext); + assertThat(CredentialEncryption.decrypt(second)).isEqualTo(plaintext); + } + + @Test + void nullsPassThrough() { + assertThat(CredentialEncryption.encrypt(null)).isNull(); + assertThat(CredentialEncryption.decrypt(null)).isNull(); + } + + @Test + void tamperedCiphertextIsRejected() { + // GCM is authenticated: flipping a byte of the stored blob must fail decryption, not + // silently return corrupted plaintext. + String encrypted = CredentialEncryption.encrypt("top-secret"); + byte[] raw = Base64.getDecoder().decode(encrypted); + raw[raw.length - 1] ^= 0x01; + String tampered = Base64.getEncoder().encodeToString(raw); + + assertThatThrownBy(() -> CredentialEncryption.decrypt(tampered)) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java new file mode 100644 index 0000000000..9f3b2d84af --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java @@ -0,0 +1,182 @@ +package stirling.software.proprietary.integration.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.access.service.OwnershipService; +import stirling.software.proprietary.access.service.SecretMasker; +import stirling.software.proprietary.integration.dto.IntegrationConfigRequest; +import stirling.software.proprietary.integration.dto.IntegrationConfigResponse; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.security.model.User; + +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +/** + * Integration-glue tests. Ownership authorization and secret masking are covered by {@code + * OwnershipServiceTest} / {@code SecretMaskerTest}; here those collaborators are mocked and we + * assert this service delegates to them correctly. + */ +@ExtendWith(MockitoExtension.class) +class IntegrationConfigServiceTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Mock private IntegrationConfigRepository repository; + @Mock private OwnershipService ownership; + @Mock private SecretMasker secretMasker; + + @InjectMocks private IntegrationConfigService service; + + @Test + void createDelegatesOwnershipAndSanitizesConfig() { + when(secretMasker.sanitize(any())).thenReturn(Map.of("bucket", "b")); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + User user = user(7); + + IntegrationConfig created = + service.create(request(IntegrationType.S3, OwnerScope.USER, null), user); + + assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.S3); + assertThat(created.getName()).isEqualTo("name"); + verify(ownership) + .assignOwnership(eq(created), eq(OwnerScope.USER), isNull(), eq(user), any()); + verify(secretMasker).sanitize(any()); + } + + @Test + void updateMergesConfigViaSecretMasker() throws Exception { + IntegrationConfig cfg = config(2L); + cfg.setConfig("{\"bucket\":\"old\",\"secretKey\":\"REAL\"}"); + when(repository.findById(2L)).thenReturn(Optional.of(cfg)); + when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true); + when(secretMasker.merge(any(), any())) + .thenReturn(Map.of("bucket", "new", "secretKey", "REAL")); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + IntegrationConfigRequest req = + new IntegrationConfigRequest( + null, + null, + null, + null, + null, + null, + null, + Map.of("bucket", "new", "secretKey", "********")); + service.update(2L, req, user(7)); + + Map stored = + MAPPER.readValue(cfg.getConfig(), new TypeReference>() {}); + assertThat(stored.get("bucket")).isEqualTo("new"); + assertThat(stored.get("secretKey")).isEqualTo("REAL"); + } + + @Test + void updateForbiddenWhenCannotManage() { + IntegrationConfig cfg = config(3L); + when(repository.findById(3L)).thenReturn(Optional.of(cfg)); + when(ownership.canManage(any(), eq(cfg), any())).thenReturn(false); + + assertThatThrownBy( + () -> + service.update( + 3L, + request(IntegrationType.S3, OwnerScope.USER, null), + user(7))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void toResponseMasksConfig() { + IntegrationConfig cfg = config(4L); + cfg.setConfig("{\"secretKey\":\"x\"}"); + when(secretMasker.mask(any())).thenReturn(Map.of("secretKey", SecretMasker.MASK)); + when(ownership.canManage(any(), eq(cfg), any())).thenReturn(false); + + IntegrationConfigResponse resp = service.toResponse(cfg, user(7)); + + assertThat(resp.config().get("secretKey")).isEqualTo(SecretMasker.MASK); + assertThat(resp.canManage()).isFalse(); + } + + @Test + void listIncludesAConfigSharedViaAnExplicitGrant() { + User user = user(7); + when(repository.findByOwnerUser(user)).thenReturn(List.of()); + when(repository.findByScope(OwnerScope.SERVER)).thenReturn(List.of()); + when(ownership.grantedResourceIds(any(), eq(user))).thenReturn(Set.of("99")); + IntegrationConfig shared = config(99L); + when(repository.findById(99L)).thenReturn(Optional.of(shared)); + when(ownership.canUse(any(), eq(shared), eq(user))).thenReturn(true); + + List visible = service.listVisible(user); + + assertThat(visible).extracting(IntegrationConfig::getId).contains(99L); + } + + @Test + void nonAdminCannotChangeLockedFlag() { + IntegrationConfig cfg = config(5L); // locked = false + when(repository.findById(5L)).thenReturn(Optional.of(cfg)); + when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true); + when(ownership.isAdmin(any())).thenReturn(false); + + IntegrationConfigRequest req = + new IntegrationConfigRequest(null, null, null, null, null, true, null, null); + + assertThatThrownBy(() -> service.update(5L, req, user(7))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + // ---- helpers ---- + + private IntegrationConfig config(long id) { + IntegrationConfig cfg = new IntegrationConfig(); + cfg.setId(id); + cfg.setIntegrationType(IntegrationType.S3); + cfg.setName("cfg" + id); + cfg.setEnabled(true); + cfg.setDefaultAccess(DefaultAccessPolicy.EXPLICIT_ONLY); + return cfg; + } + + private IntegrationConfigRequest request(IntegrationType type, OwnerScope scope, Long teamId) { + return new IntegrationConfigRequest( + type, "name", scope, teamId, null, null, null, Map.of("bucket", "b")); + } + + private User user(long id) { + User u = new User(); + u.setId(id); + u.setUsername("user" + id); + return u; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java index 73b52fcd4c..6a27b4d578 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java @@ -28,6 +28,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.service.ResourceAccessService; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; @@ -58,6 +59,7 @@ class AuthControllerLoginTest { @Mock private MfaService mfaService; @Mock private TotpService totpService; @Mock private RefreshRateLimitService refreshRateLimitService; + @Mock private ResourceAccessService resourceAccessService; @BeforeEach void setUp() { @@ -80,7 +82,8 @@ class AuthControllerLoginTest { refreshRateLimitService, securityProperties, applicationProperties, - new stirling.software.proprietary.service.AiUserDataService(null)); + new stirling.software.proprietary.service.AiUserDataService(null), + resourceAccessService); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java index 8f5b9e9235..611929b5b9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java @@ -28,6 +28,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.service.ResourceAccessService; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; @@ -59,6 +60,7 @@ class AuthControllerMoreTest { @Mock private MfaService mfaService; @Mock private TotpService totpService; @Mock private RefreshRateLimitService refreshRateLimitService; + @Mock private ResourceAccessService resourceAccessService; @BeforeEach void setUp() { @@ -81,7 +83,8 @@ class AuthControllerMoreTest { refreshRateLimitService, securityProperties, applicationProperties, - new stirling.software.proprietary.service.AiUserDataService(null)); + new stirling.software.proprietary.service.AiUserDataService(null), + resourceAccessService); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } diff --git a/app/saas/src/main/resources/db/migration/saas/V25__resource_grants.sql b/app/saas/src/main/resources/db/migration/saas/V25__resource_grants.sql new file mode 100644 index 0000000000..f1eab58b15 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V25__resource_grants.sql @@ -0,0 +1,21 @@ +-- Resource access grants: which user/team may use a gated resource (portal, integration config). + +CREATE TABLE IF NOT EXISTS resource_grants ( + resource_grant_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + resource_type VARCHAR(64) NOT NULL, + resource_id VARCHAR(255) NOT NULL DEFAULT '', + principal_type VARCHAR(32) NOT NULL, + principal_id BIGINT NOT NULL, + permission VARCHAR(32) NOT NULL, + granted_by_user_id BIGINT, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_resource_grant + ON resource_grants (resource_type, resource_id, principal_type, principal_id, permission); + +CREATE INDEX IF NOT EXISTS idx_resource_grants_lookup + ON resource_grants (resource_type, resource_id); + +CREATE INDEX IF NOT EXISTS idx_resource_grants_principal + ON resource_grants (principal_type, principal_id); diff --git a/app/saas/src/main/resources/db/migration/saas/V26__integration_configs.sql b/app/saas/src/main/resources/db/migration/saas/V26__integration_configs.sql new file mode 100644 index 0000000000..c78660248e --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V26__integration_configs.sql @@ -0,0 +1,25 @@ +-- S3/MCP/API integration configs; config_encrypted holds an AES-GCM encrypted JSON blob. + +CREATE TABLE IF NOT EXISTS integration_configs ( + integration_config_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + integration_type VARCHAR(32) NOT NULL, + name VARCHAR(255) NOT NULL, + scope VARCHAR(32) NOT NULL, + owner_user_id BIGINT, + owner_team_id BIGINT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + locked BOOLEAN NOT NULL DEFAULT FALSE, + default_access VARCHAR(32) NOT NULL DEFAULT 'EXPLICIT_ONLY', + config_encrypted TEXT, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_integration_configs_owner + ON integration_configs (owner_user_id); + +CREATE INDEX IF NOT EXISTS idx_integration_configs_type + ON integration_configs (integration_type); + +CREATE INDEX IF NOT EXISTS idx_integration_configs_scope + ON integration_configs (scope); diff --git a/frontend/editor/src/proprietary/routes/Login.test.tsx b/frontend/editor/src/proprietary/routes/Login.test.tsx index c83b9c500c..f70aa6ef95 100644 --- a/frontend/editor/src/proprietary/routes/Login.test.tsx +++ b/frontend/editor/src/proprietary/routes/Login.test.tsx @@ -114,6 +114,7 @@ describe("Login", () => { displayName: null, isAnonymous: false, isAdmin: false, + portalAccess: false, role: null, loading: false, error: null, @@ -169,6 +170,7 @@ describe("Login", () => { displayName: mockSession.user.username, isAnonymous: false, isAdmin: false, + portalAccess: false, role: mockSession.user.role, loading: false, error: null, @@ -196,6 +198,7 @@ describe("Login", () => { displayName: null, isAnonymous: false, isAdmin: false, + portalAccess: false, role: null, loading: true, error: null, diff --git a/frontend/portal/src/components/AuthGate.tsx b/frontend/portal/src/components/AuthGate.tsx index 774b269c9f..6d923abd12 100644 --- a/frontend/portal/src/components/AuthGate.tsx +++ b/frontend/portal/src/components/AuthGate.tsx @@ -1,15 +1,11 @@ import { type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { RequireAdmin } from "@shared/auth"; +import { RequirePortalAccess } from "@shared/auth"; import { Spinner } from "@shared/components"; import { LoginScreen } from "@portal/components/LoginScreen"; import { EDITOR_URL } from "@portal/auth/editorUrl"; -/** - * Module-level so the reference is stable across renders (RequireAdmin runs it - * from an effect). The portal is admin-only today; authenticated non-admins are - * bounced to the editor rather than shown an access-denied page. - */ +// Stable module-level ref; RequirePortalAccess calls it from an effect. function redirectToEditor(): void { window.location.href = EDITOR_URL; } @@ -31,17 +27,11 @@ function FullScreenMessage({ children }: { children: ReactNode }) { ); } -/** - * Gates the whole portal behind an authenticated admin session: - * - loading -> spinner - * - signed out -> login screen - * - signed in, not admin -> redirect to the editor - * - signed in admin -> the portal - */ +/** Gates the portal: login when signed out, redirect to the editor without portal access. */ export function AuthGate({ children }: { children: ReactNode }) { const { t } = useTranslation(); return ( - } onForbidden={redirectToEditor} loading={ @@ -56,6 +46,6 @@ export function AuthGate({ children }: { children: ReactNode }) { } > {children} - + ); } diff --git a/frontend/shared/auth/context.ts b/frontend/shared/auth/context.ts index fdc751996b..41ea15e889 100644 --- a/frontend/shared/auth/context.ts +++ b/frontend/shared/auth/context.ts @@ -12,6 +12,7 @@ export const AuthContext = createContext({ displayName: null, isAnonymous: false, isAdmin: false, + portalAccess: false, role: null, loading: true, error: null, diff --git a/frontend/shared/auth/guards/RequirePortalAccess.tsx b/frontend/shared/auth/guards/RequirePortalAccess.tsx new file mode 100644 index 0000000000..0e6e223013 --- /dev/null +++ b/frontend/shared/auth/guards/RequirePortalAccess.tsx @@ -0,0 +1,35 @@ +// Renders children only for a user with portal access; otherwise calls onForbidden. +import { useEffect, type ReactNode } from "react"; +import { useAuth } from "@shared/auth/context"; + +export interface RequirePortalAccessProps { + children: ReactNode; + /** Rendered when there is no session (e.g. the login panel). */ + fallback: ReactNode; + /** Invoked once when an authenticated user without portal access is detected. */ + onForbidden: () => void; + /** Rendered while the session is still resolving. */ + loading?: ReactNode; + /** Rendered for an authenticated user without portal access. */ + forbidden?: ReactNode; +} + +export function RequirePortalAccess({ + children, + fallback, + onForbidden, + loading = null, + forbidden = null, +}: RequirePortalAccessProps) { + const { session, loading: isLoading, portalAccess } = useAuth(); + + const shouldRedirect = !isLoading && !!session && !portalAccess; + useEffect(() => { + if (shouldRedirect) onForbidden(); + }, [shouldRedirect, onForbidden]); + + if (isLoading) return <>{loading}; + if (!session) return <>{fallback}; + if (!portalAccess) return <>{forbidden}; + return <>{children}; +} diff --git a/frontend/shared/auth/index.ts b/frontend/shared/auth/index.ts index 4cb9599c48..61a99c87ac 100644 --- a/frontend/shared/auth/index.ts +++ b/frontend/shared/auth/index.ts @@ -28,6 +28,10 @@ export { RequireAdmin, type RequireAdminProps, } from "@shared/auth/guards/RequireAdmin"; +export { + RequirePortalAccess, + type RequirePortalAccessProps, +} from "@shared/auth/guards/RequirePortalAccess"; // Spring backend export { diff --git a/frontend/shared/auth/spring/UseSession.tsx b/frontend/shared/auth/spring/UseSession.tsx index 3c76fbf0c6..9e0954ef82 100644 --- a/frontend/shared/auth/spring/UseSession.tsx +++ b/frontend/shared/auth/spring/UseSession.tsx @@ -195,6 +195,7 @@ export function SpringAuthProvider({ displayName: deriveDisplayName(user, translate), isAnonymous: user?.is_anonymous === true, isAdmin: isAdminRole(user?.role), + portalAccess: user?.portalAccess ?? isAdminRole(user?.role), role: user?.role ?? null, loading, error, diff --git a/frontend/shared/auth/supabase/UseSession.tsx b/frontend/shared/auth/supabase/UseSession.tsx index 51ad3da326..81cf6a9842 100644 --- a/frontend/shared/auth/supabase/UseSession.tsx +++ b/frontend/shared/auth/supabase/UseSession.tsx @@ -148,6 +148,7 @@ export function SupabaseAuthProvider({ displayName: deriveDisplayName(user, translate), isAnonymous: user?.is_anonymous === true, isAdmin: isAdminRole(user?.role), + portalAccess: user?.portalAccess ?? isAdminRole(user?.role), role: user?.role ?? null, loading, error, diff --git a/frontend/shared/auth/types.ts b/frontend/shared/auth/types.ts index 777805681d..c69adb033b 100644 --- a/frontend/shared/auth/types.ts +++ b/frontend/shared/auth/types.ts @@ -16,6 +16,8 @@ export interface AuthUser { is_anonymous?: boolean; isFirstLogin?: boolean; authenticationType?: string; + /** Whether the backend grants this user portal/processor access. */ + portalAccess?: boolean; app_metadata?: Record; } @@ -58,6 +60,8 @@ export interface AuthContextValue { isAnonymous: boolean; /** True when the current user holds an admin role. */ isAdmin: boolean; + /** True when the user may access the portal/processor (admins + ACL grants). */ + portalAccess: boolean; /** Raw backend role string, or null when signed out. */ role: string | null; loading: boolean;