Add portal access control and S3/MCP/API integration configs (#6795)

This commit is contained in:
Anthony Stirling
2026-07-01 13:49:02 +01:00
committed by GitHub
parent 467f3a86c4
commit 6c85200eb9
44 changed files with 2299 additions and 19 deletions
@@ -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();
}
}
@@ -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<ResourceGrant> 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<String, Object> toDto(ResourceGrant g) {
Map<String, Object> 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) {}
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.access.model;
/** Permission level a grant confers. MANAGE implies USE. */
public enum AccessPermission {
USE,
MANAGE
}
@@ -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
}
@@ -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;
}
}
@@ -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
}
@@ -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
}
@@ -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;
}
@@ -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
}
@@ -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<ResourceGrant, Long> {
List<ResourceGrant> findByResourceTypeAndResourceId(
ResourceType resourceType, String resourceId);
List<ResourceGrant> findByResourceTypeAndPrincipalTypeAndPrincipalId(
ResourceType resourceType, PrincipalType principalType, Long principalId);
void deleteByResourceTypeAndResourceId(ResourceType resourceType, String resourceId);
boolean existsByResourceTypeAndResourceIdAndPrincipalTypeAndPrincipalId(
ResourceType resourceType,
String resourceId,
PrincipalType principalType,
Long principalId);
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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<String> 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);
}
}
@@ -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<ResourceGrant> 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<String> grantedResourceIds(ResourceType type, User user) {
if (user == null) {
return Set.of();
}
Set<String> 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;
}
}
@@ -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<String> SENSITIVE_HINTS =
Set.of(
"secret",
"password",
"token",
"apikey",
"accesskey",
"credential",
"privatekey");
/** Replace sensitive values with the mask (recursively) for safe display. */
public Map<String, Object> mask(Map<String, Object> config) {
return mask(config, 0);
}
/** Drop sensitive blank/masked values from an incoming create payload. */
public Map<String, Object> sanitize(Map<String, Object> config) {
return sanitize(config, 0);
}
/**
* Merge an update over the stored map, keeping stored secrets where the incoming is redacted.
*/
public Map<String, Object> merge(Map<String, Object> stored, Map<String, Object> incoming) {
return merge(stored, incoming, 0);
}
private Map<String, Object> mask(Map<String, Object> config, int depth) {
Map<String, Object> out = new LinkedHashMap<>();
for (Map.Entry<String, Object> e : config.entrySet()) {
out.put(e.getKey(), maskValue(e.getKey(), e.getValue(), depth));
}
return out;
}
private Map<String, Object> sanitize(Map<String, Object> config, int depth) {
if (config == null) {
return new LinkedHashMap<>();
}
Map<String, Object> out = new LinkedHashMap<>();
for (Map.Entry<String, Object> 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<String, Object> merge(
Map<String, Object> stored, Map<String, Object> incoming, int depth) {
Map<String, Object> out = new LinkedHashMap<>(stored);
for (Map.Entry<String, Object> 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<Object> 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<String, Object> castMap(Map<?, ?> map) {
return (Map<String, Object>) map;
}
}
@@ -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);
}
@@ -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<IntegrationConfigResponse>> list(
@AuthenticationPrincipal User user) {
requireUser(user);
return ResponseEntity.ok(
service.listVisible(user).stream().map(c -> service.toResponse(c, user)).toList());
}
@PostMapping
public ResponseEntity<IntegrationConfigResponse> create(
@RequestBody IntegrationConfigRequest request, @AuthenticationPrincipal User user) {
requireUser(user);
return ResponseEntity.ok(service.toResponse(service.create(request, user), user));
}
@GetMapping("/{id}")
public ResponseEntity<IntegrationConfigResponse> get(
@PathVariable Long id, @AuthenticationPrincipal User user) {
requireUser(user);
return ResponseEntity.ok(service.toResponse(service.getForUse(id, user), user));
}
@PutMapping("/{id}")
public ResponseEntity<IntegrationConfigResponse> 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<Void> 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");
}
}
}
@@ -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;
}
}
@@ -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<String, String> {
@Override
public String convertToDatabaseColumn(String attribute) {
return CredentialEncryption.encrypt(attribute);
}
@Override
public String convertToEntityAttribute(String dbData) {
return CredentialEncryption.decrypt(dbData);
}
}
@@ -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<String, Object> config) {}
@@ -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<String, Object> config,
boolean canManage,
LocalDateTime createdAt,
LocalDateTime updatedAt) {}
@@ -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;
}
@@ -0,0 +1,8 @@
package stirling.software.proprietary.integration.model;
/** Kind of external integration a stored config describes. */
public enum IntegrationType {
S3,
MCP,
API
}
@@ -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<IntegrationConfig, Long> {
List<IntegrationConfig> findByOwnerUser(User ownerUser);
List<IntegrationConfig> findByOwnerTeam(Team ownerTeam);
List<IntegrationConfig> findByScope(OwnerScope scope);
}
@@ -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<IntegrationConfig> listVisible(User currentUser) {
Map<Long, IntegrationConfig> 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<String, Object> 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<String, Object> readJson(String json) {
if (json == null || json.isBlank()) {
return new LinkedHashMap<>();
}
try {
return OBJECT_MAPPER.readValue(
json, new TypeReference<LinkedHashMap<String, Object>>() {});
} catch (Exception e) {
log.error("Failed to parse integration config JSON", e);
return new LinkedHashMap<>();
}
}
private <T> 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);
}
}
@@ -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 {
@@ -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
@@ -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;
}
}
@@ -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;
}
}
@@ -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<String, Object> nested = new LinkedHashMap<>();
nested.put("host", "h.example");
nested.put("secretKey", "sk-real");
Map<String, Object> config = new LinkedHashMap<>();
config.put("bucket", "acme");
config.put("accessKey", "AKIA123");
config.put("connection", nested); // non-sensitive parent, sensitive child
Map<String, Object> masked = masker.mask(config);
assertThat(masked.get("bucket")).isEqualTo("acme");
assertThat(masked.get("accessKey")).isEqualTo(SecretMasker.MASK);
@SuppressWarnings("unchecked")
Map<String, Object> conn = (Map<String, Object>) masked.get("connection");
assertThat(conn.get("host")).isEqualTo("h.example");
assertThat(conn.get("secretKey")).isEqualTo(SecretMasker.MASK);
}
@Test
void mergeKeepsStoredSecretWhenIncomingIsMasked() {
Map<String, Object> stored = Map.of("bucket", "old", "secretKey", "REAL");
Map<String, Object> incoming = Map.of("bucket", "new", "secretKey", SecretMasker.MASK);
Map<String, Object> 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<String, Object> merged =
masker.merge(Map.of("secretKey", "OLD"), Map.of("secretKey", "NEW"));
assertThat(merged.get("secretKey")).isEqualTo("NEW");
}
@Test
void sanitizeDropsBlankSecretsOnCreate() {
Map<String, Object> incoming = new LinkedHashMap<>();
incoming.put("bucket", "b");
incoming.put("secretKey", "");
Map<String, Object> clean = masker.sanitize(incoming);
assertThat(clean).containsEntry("bucket", "b").doesNotContainKey("secretKey");
}
@Test
void deeplyNestedInputIsBoundedNotOverflowing() {
// Build a structure far deeper than the recursion cap.
Map<String, Object> root = new LinkedHashMap<>();
Map<String, Object> cur = root;
for (int i = 0; i < 2000; i++) {
Map<String, Object> 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();
}
}
@@ -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);
}
}
@@ -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<String, Object> stored =
MAPPER.readValue(cfg.getConfig(), new TypeReference<Map<String, Object>>() {});
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<IntegrationConfig> 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;
}
}
@@ -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();
}
@@ -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();
}
@@ -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);
@@ -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);
@@ -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,
+5 -15
View File
@@ -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 (
<RequireAdmin
<RequirePortalAccess
fallback={<LoginScreen />}
onForbidden={redirectToEditor}
loading={
@@ -56,6 +46,6 @@ export function AuthGate({ children }: { children: ReactNode }) {
}
>
{children}
</RequireAdmin>
</RequirePortalAccess>
);
}
+1
View File
@@ -12,6 +12,7 @@ export const AuthContext = createContext<AuthContextValue>({
displayName: null,
isAnonymous: false,
isAdmin: false,
portalAccess: false,
role: null,
loading: true,
error: null,
@@ -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}</>;
}
+4
View File
@@ -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 {
@@ -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,
@@ -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,
+4
View File
@@ -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<string, unknown>;
}
@@ -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;