Skip to content

Commit 6c85200

Browse files
authored
Add portal access control and S3/MCP/API integration configs (Stirling-Tools#6795)
1 parent 467f3a8 commit 6c85200

44 files changed

Lines changed: 2299 additions & 19 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package stirling.software.proprietary.access.config;
2+
3+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
7+
import stirling.software.proprietary.access.service.DefaultTeamLeadLookup;
8+
import stirling.software.proprietary.access.service.TeamLeadLookup;
9+
10+
/** Access-layer bean wiring. */
11+
@Configuration
12+
public class AccessConfig {
13+
14+
/** No-op {@link TeamLeadLookup} unless another bean is defined. */
15+
@Bean
16+
@ConditionalOnMissingBean(TeamLeadLookup.class)
17+
TeamLeadLookup defaultTeamLeadLookup() {
18+
return new DefaultTeamLeadLookup();
19+
}
20+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package stirling.software.proprietary.access.controller;
2+
3+
import java.util.HashMap;
4+
import java.util.List;
5+
import java.util.Map;
6+
7+
import org.springframework.http.ResponseEntity;
8+
import org.springframework.security.access.prepost.PreAuthorize;
9+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
10+
import org.springframework.web.bind.annotation.DeleteMapping;
11+
import org.springframework.web.bind.annotation.GetMapping;
12+
import org.springframework.web.bind.annotation.PathVariable;
13+
import org.springframework.web.bind.annotation.PostMapping;
14+
import org.springframework.web.bind.annotation.RequestBody;
15+
import org.springframework.web.bind.annotation.RequestMapping;
16+
import org.springframework.web.bind.annotation.RequestParam;
17+
import org.springframework.web.bind.annotation.RestController;
18+
19+
import io.swagger.v3.oas.annotations.tags.Tag;
20+
21+
import lombok.RequiredArgsConstructor;
22+
23+
import stirling.software.proprietary.access.model.AccessPermission;
24+
import stirling.software.proprietary.access.model.PrincipalType;
25+
import stirling.software.proprietary.access.model.ResourceGrant;
26+
import stirling.software.proprietary.access.model.ResourceType;
27+
import stirling.software.proprietary.access.service.ResourceAccessService;
28+
import stirling.software.proprietary.security.model.User;
29+
30+
/** Admin endpoints to grant/revoke access to gated resources (the portal, integration configs). */
31+
@RestController
32+
@RequestMapping("/api/v1/admin/access")
33+
@RequiredArgsConstructor
34+
@PreAuthorize("hasRole('ADMIN')")
35+
@Tag(name = "Access Control", description = "Manage resource access grants (portal, integrations)")
36+
public class ResourceGrantController {
37+
38+
private final ResourceAccessService accessService;
39+
40+
@GetMapping("/grants")
41+
public ResponseEntity<?> list(
42+
@RequestParam ResourceType resourceType,
43+
@RequestParam(required = false, defaultValue = "") String resourceId) {
44+
List<ResourceGrant> grants = accessService.listGrants(resourceType, resourceId);
45+
return ResponseEntity.ok(grants.stream().map(this::toDto).toList());
46+
}
47+
48+
@PostMapping("/grants")
49+
public ResponseEntity<?> create(
50+
@RequestBody GrantRequest request, @AuthenticationPrincipal User admin) {
51+
if (request.resourceType() == null
52+
|| request.principalType() == null
53+
|| request.principalId() == null) {
54+
return ResponseEntity.badRequest()
55+
.body(
56+
Map.of(
57+
"error",
58+
"resourceType, principalType and principalId are required"));
59+
}
60+
AccessPermission permission =
61+
request.permission() == null ? AccessPermission.USE : request.permission();
62+
// PORTAL is a singleton resource; its grants always target the whole type.
63+
String resourceId =
64+
request.resourceType() == ResourceType.PORTAL ? "" : request.resourceId();
65+
ResourceGrant grant =
66+
accessService.grant(
67+
request.resourceType(),
68+
resourceId,
69+
request.principalType(),
70+
request.principalId(),
71+
permission,
72+
admin);
73+
return ResponseEntity.ok(toDto(grant));
74+
}
75+
76+
@DeleteMapping("/grants/{id}")
77+
public ResponseEntity<?> delete(@PathVariable Long id) {
78+
accessService.revoke(id);
79+
return ResponseEntity.ok(Map.of("message", "Grant revoked"));
80+
}
81+
82+
private Map<String, Object> toDto(ResourceGrant g) {
83+
Map<String, Object> m = new HashMap<>();
84+
m.put("id", g.getId());
85+
m.put("resourceType", g.getResourceType());
86+
m.put("resourceId", g.getResourceId());
87+
m.put("principalType", g.getPrincipalType());
88+
m.put("principalId", g.getPrincipalId());
89+
m.put("permission", g.getPermission());
90+
m.put("createdAt", g.getCreatedAt());
91+
return m;
92+
}
93+
94+
/** Request body for creating a grant. */
95+
public record GrantRequest(
96+
ResourceType resourceType,
97+
String resourceId,
98+
PrincipalType principalType,
99+
Long principalId,
100+
AccessPermission permission) {}
101+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package stirling.software.proprietary.access.model;
2+
3+
/** Permission level a grant confers. MANAGE implies USE. */
4+
public enum AccessPermission {
5+
USE,
6+
MANAGE
7+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package stirling.software.proprietary.access.model;
2+
3+
/**
4+
* Fallback policy applied when no explicit {@link ResourceGrant} matches. Admins (org owners)
5+
* always pass regardless of this policy.
6+
*/
7+
public enum DefaultAccessPolicy {
8+
// Every authenticated user in the deployment (org) may use the resource.
9+
ORG_ALL,
10+
// Only org admins and team leaders. This is the default for the portal.
11+
ADMINS_AND_TEAM_LEADS,
12+
// Nobody but the owner, admins, and explicit grantees.
13+
EXPLICIT_ONLY
14+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package stirling.software.proprietary.access.model;
2+
3+
import jakarta.persistence.Column;
4+
import jakarta.persistence.EnumType;
5+
import jakarta.persistence.Enumerated;
6+
import jakarta.persistence.FetchType;
7+
import jakarta.persistence.JoinColumn;
8+
import jakarta.persistence.ManyToOne;
9+
import jakarta.persistence.MappedSuperclass;
10+
11+
import lombok.Getter;
12+
import lombok.Setter;
13+
14+
import stirling.software.proprietary.model.Team;
15+
import stirling.software.proprietary.security.model.User;
16+
17+
/** Base for a resource owned by a user, a team, or the server, with grant-based access. */
18+
@MappedSuperclass
19+
@Getter
20+
@Setter
21+
public abstract class OwnedResource {
22+
23+
@Enumerated(EnumType.STRING)
24+
@Column(name = "scope", nullable = false, length = 32)
25+
private OwnerScope scope;
26+
27+
@ManyToOne(fetch = FetchType.LAZY)
28+
@JoinColumn(name = "owner_user_id")
29+
private User ownerUser;
30+
31+
@ManyToOne(fetch = FetchType.LAZY)
32+
@JoinColumn(name = "owner_team_id")
33+
private Team ownerTeam;
34+
35+
@Column(name = "enabled", nullable = false)
36+
private boolean enabled = true;
37+
38+
// Server resource that users cannot override with their own of the same kind.
39+
@Column(name = "locked", nullable = false)
40+
private boolean locked = false;
41+
42+
// Who, besides owner/admin/grantees, may use this resource.
43+
@Enumerated(EnumType.STRING)
44+
@Column(name = "default_access", nullable = false, length = 32)
45+
private DefaultAccessPolicy defaultAccess = DefaultAccessPolicy.EXPLICIT_ONLY;
46+
47+
/** Subclass primary key. */
48+
public abstract Long getId();
49+
50+
public Long getOwnerUserId() {
51+
return ownerUser != null ? ownerUser.getId() : null;
52+
}
53+
54+
public Long getOwnerTeamId() {
55+
return ownerTeam != null ? ownerTeam.getId() : null;
56+
}
57+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package stirling.software.proprietary.access.model;
2+
3+
/** Ownership scope of an {@link OwnedResource}: a single user, a team, or the whole server. */
4+
public enum OwnerScope {
5+
USER,
6+
TEAM,
7+
SERVER
8+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package stirling.software.proprietary.access.model;
2+
3+
/** Who a {@link ResourceGrant} is granted to. Org-wide access is expressed via default policy. */
4+
public enum PrincipalType {
5+
USER,
6+
TEAM
7+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package stirling.software.proprietary.access.model;
2+
3+
import java.io.Serializable;
4+
import java.time.LocalDateTime;
5+
6+
import org.hibernate.annotations.CreationTimestamp;
7+
8+
import jakarta.persistence.Column;
9+
import jakarta.persistence.Entity;
10+
import jakarta.persistence.EnumType;
11+
import jakarta.persistence.Enumerated;
12+
import jakarta.persistence.FetchType;
13+
import jakarta.persistence.GeneratedValue;
14+
import jakarta.persistence.GenerationType;
15+
import jakarta.persistence.Id;
16+
import jakarta.persistence.Index;
17+
import jakarta.persistence.JoinColumn;
18+
import jakarta.persistence.ManyToOne;
19+
import jakarta.persistence.Table;
20+
import jakarta.persistence.UniqueConstraint;
21+
22+
import lombok.Getter;
23+
import lombok.NoArgsConstructor;
24+
import lombok.Setter;
25+
26+
import stirling.software.proprietary.security.model.User;
27+
28+
/** Grants a user or team access to a resource. Owner and admin access are implicit. */
29+
@Entity
30+
@Table(
31+
name = "resource_grants",
32+
uniqueConstraints =
33+
@UniqueConstraint(
34+
name = "uk_resource_grant",
35+
columnNames = {
36+
"resource_type",
37+
"resource_id",
38+
"principal_type",
39+
"principal_id",
40+
"permission"
41+
}),
42+
indexes = {
43+
@Index(name = "idx_resource_grants_lookup", columnList = "resource_type,resource_id"),
44+
@Index(
45+
name = "idx_resource_grants_principal",
46+
columnList = "principal_type,principal_id")
47+
})
48+
@NoArgsConstructor
49+
@Getter
50+
@Setter
51+
public class ResourceGrant implements Serializable {
52+
53+
private static final long serialVersionUID = 1L;
54+
55+
@Id
56+
@GeneratedValue(strategy = GenerationType.IDENTITY)
57+
@Column(name = "resource_grant_id")
58+
private Long id;
59+
60+
@Enumerated(EnumType.STRING)
61+
@Column(name = "resource_type", nullable = false, length = 64)
62+
private ResourceType resourceType;
63+
64+
// Empty string (never null) for a whole-type grant such as the portal.
65+
@Column(name = "resource_id", nullable = false, length = 255)
66+
private String resourceId = "";
67+
68+
@Enumerated(EnumType.STRING)
69+
@Column(name = "principal_type", nullable = false, length = 32)
70+
private PrincipalType principalType;
71+
72+
@Column(name = "principal_id", nullable = false)
73+
private Long principalId;
74+
75+
@Enumerated(EnumType.STRING)
76+
@Column(name = "permission", nullable = false, length = 32)
77+
private AccessPermission permission;
78+
79+
@ManyToOne(fetch = FetchType.LAZY)
80+
@JoinColumn(name = "granted_by_user_id")
81+
private User grantedBy;
82+
83+
@CreationTimestamp
84+
@Column(name = "created_at", updatable = false)
85+
private LocalDateTime createdAt;
86+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package stirling.software.proprietary.access.model;
2+
3+
/** Types of resources whose access can be gated by {@link ResourceGrant}. */
4+
public enum ResourceType {
5+
// The admin portal / processor (frontend/portal). Singleton resource (empty resourceId).
6+
PORTAL,
7+
// A stored S3/MCP/API integration configuration.
8+
INTEGRATION_CONFIG
9+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package stirling.software.proprietary.access.repository;
2+
3+
import java.util.List;
4+
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.stereotype.Repository;
7+
8+
import stirling.software.proprietary.access.model.PrincipalType;
9+
import stirling.software.proprietary.access.model.ResourceGrant;
10+
import stirling.software.proprietary.access.model.ResourceType;
11+
12+
@Repository
13+
public interface ResourceGrantRepository extends JpaRepository<ResourceGrant, Long> {
14+
15+
List<ResourceGrant> findByResourceTypeAndResourceId(
16+
ResourceType resourceType, String resourceId);
17+
18+
List<ResourceGrant> findByResourceTypeAndPrincipalTypeAndPrincipalId(
19+
ResourceType resourceType, PrincipalType principalType, Long principalId);
20+
21+
void deleteByResourceTypeAndResourceId(ResourceType resourceType, String resourceId);
22+
23+
boolean existsByResourceTypeAndResourceIdAndPrincipalTypeAndPrincipalId(
24+
ResourceType resourceType,
25+
String resourceId,
26+
PrincipalType principalType,
27+
Long principalId);
28+
}

0 commit comments

Comments
 (0)