Add portal access control and S3/MCP/API integration configs (#6795)
This commit is contained in:
+20
@@ -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();
|
||||
}
|
||||
}
|
||||
+101
@@ -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) {}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/** Permission level a grant confers. MANAGE implies USE. */
|
||||
public enum AccessPermission {
|
||||
USE,
|
||||
MANAGE
|
||||
}
|
||||
+14
@@ -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
|
||||
}
|
||||
+57
@@ -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;
|
||||
}
|
||||
}
|
||||
+8
@@ -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
|
||||
}
|
||||
+7
@@ -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
|
||||
}
|
||||
+86
@@ -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;
|
||||
}
|
||||
+9
@@ -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
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
}
|
||||
+116
@@ -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);
|
||||
}
|
||||
}
|
||||
+188
@@ -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;
|
||||
}
|
||||
}
|
||||
+151
@@ -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;
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+81
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+18
@@ -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) {}
|
||||
+24
@@ -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) {}
|
||||
+67
@@ -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;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package stirling.software.proprietary.integration.model;
|
||||
|
||||
/** Kind of external integration a stored config describes. */
|
||||
public enum IntegrationType {
|
||||
S3,
|
||||
MCP,
|
||||
API
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
+226
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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 {
|
||||
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+174
@@ -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;
|
||||
}
|
||||
}
|
||||
+217
@@ -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;
|
||||
}
|
||||
}
|
||||
+80
@@ -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();
|
||||
}
|
||||
}
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
+182
@@ -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;
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user