Skip to content

Commit 3f7e898

Browse files
authored
Add sources service and frontend (Stirling-Tools#6774)
# Description of Changes Redesign policies backend to treat sources a lot closer to how the frontend imagined them working (they're persistent now and have an API). Then connect the portal to the sources when mocks are off to allow for source creation in the UI. It's not particularly useful to do that right now because there's no policies UI, but I've tested manually that sources set up in the UI are usable by policies created via the API. I had to change the portal so that when mocks are off, it doesn't just hard crash when attempting to connect to all the backend APIs that don't exist yet. It'll still log the errors, but just continues on rendering the UI now. I also changed all the policies backend APIs to be gated behind a flag instead of behind the SaaS profile. This is because we haven't yet got the payment model sorted, but we're going to need this stuff running self-hosted to be able to test it locally.
1 parent def3cf7 commit 3f7e898

84 files changed

Lines changed: 3094 additions & 1353 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.taskfiles/backend.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ tasks:
2626
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
2727
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
2828
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
29+
POLICIES_ENABLED: '{{.POLICIES_ENABLED}}'
2930

3031
dev:proprietary:
3132
desc: "Start backend dev server in proprietary mode"
@@ -36,12 +37,13 @@ tasks:
3637
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
3738
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
3839
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
40+
POLICIES_ENABLED: '{{.POLICIES_ENABLED | default ""}}'
3941
env:
4042
SERVER_PORT: '{{.PORT}}'
4143
cmds:
42-
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
44+
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
4345
platforms: [windows]
44-
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
46+
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}./gradlew :stirling-pdf:bootRun'
4547
platforms: [linux, darwin]
4648

4749
dev:bundled:

.taskfiles/frontend.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,10 +404,22 @@ tasks:
404404

405405
test:
406406
desc: "Run tests"
407+
cmds:
408+
- task: test:editor
409+
- task: test:portal
410+
411+
test:editor:
412+
desc: "Run editor tests"
407413
deps: [prepare]
408414
cmds:
409415
- npx vitest run --root editor
410416

417+
test:portal:
418+
desc: "Run portal tests"
419+
deps: [prepare]
420+
cmds:
421+
- npx vitest run --root portal
422+
411423
test:watch:
412424
desc: "Run tests in watch mode"
413425
deps: [prepare]

Taskfile.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ tasks:
9090
vars:
9191
PORT: '{{.BACKEND_PORT}}'
9292
SECURITY_ENABLELOGIN: "true"
93+
POLICIES_ENABLED: "true"
9394
- task: frontend:dev:portal
9495
vars:
9596
PORT: '{{.PORTAL_PORT}}'
@@ -109,6 +110,7 @@ tasks:
109110
vars:
110111
PORT: '{{.BACKEND_PORT}}'
111112
SECURITY_ENABLELOGIN: "true"
113+
POLICIES_ENABLED: "true"
112114
- task: frontend:dev:portal
113115
vars:
114116
PORT: '{{.PORTAL_PORT}}'
@@ -135,6 +137,7 @@ tasks:
135137
vars:
136138
PORT: '{{.BACKEND_PORT}}'
137139
SECURITY_ENABLELOGIN: "true"
140+
POLICIES_ENABLED: "true"
138141
- task: frontend:dev:proprietary
139142
vars:
140143
PORT: '{{.EDITOR_PORT}}'
@@ -210,6 +213,7 @@ tasks:
210213
vars:
211214
PORT: '{{.BACKEND_PORT}}'
212215
SECURITY_ENABLELOGIN: "true"
216+
POLICIES_ENABLED: "true"
213217
- task: frontend:preview:portal:proxy
214218
vars:
215219
PORT: '{{.PROXY_PORT}}'

app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@ public static class FileReadiness {
206206

207207
@Data
208208
public static class Policies {
209+
/**
210+
* Master switch for the policy + sources subsystem (the PAYG-metered automation surface).
211+
*/
212+
private boolean enabled = false;
213+
209214
/**
210215
* Absolute directories that policy folder input sources and output sinks may read from or
211216
* write to. Empty (the default) disables folder access entirely, so a policy can never be

app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44
import java.util.ArrayList;
55
import java.util.Arrays;
66
import java.util.List;
7+
import java.util.Optional;
78

8-
import org.springframework.context.annotation.Profile;
9+
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
910
import org.springframework.core.env.Environment;
1011
import org.springframework.stereotype.Component;
1112

1213
import stirling.software.common.configuration.InstallationPathConfig;
1314
import stirling.software.common.model.ApplicationProperties;
1415
import stirling.software.proprietary.policy.model.Policy;
16+
import stirling.software.proprietary.policy.source.SourceStore;
1517

1618
/**
1719
* Authority on which filesystem locations a policy may read/write. Checked at save time and again
@@ -28,20 +30,25 @@
2830
* defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted.
2931
*/
3032
@Component
31-
@Profile("saas")
33+
@ConditionalOnBooleanProperty(name = "policies.enabled")
3234
public class FolderAccessGuard {
3335

3436
public static final String FOLDER_TYPE = "folder";
3537

3638
private final boolean saasActive;
3739
private final List<Path> allowedRoots;
3840
private final List<Path> protectedRoots;
41+
private final SourceStore sourceStore;
3942

40-
public FolderAccessGuard(ApplicationProperties applicationProperties, Environment environment) {
43+
public FolderAccessGuard(
44+
ApplicationProperties applicationProperties,
45+
Environment environment,
46+
SourceStore sourceStore) {
4147
this.saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas");
4248
this.allowedRoots =
4349
normalizeAll(applicationProperties.getPolicies().getAllowedFolderRoots());
4450
this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath())));
51+
this.sourceStore = sourceStore;
4552
}
4653

4754
/** Returns the normalised absolute path; throws if not permitted. */
@@ -72,7 +79,10 @@ public Path requirePermitted(Path dir) {
7279
/** Whether this policy touches a folder source/sink, and so is subject to these rules. */
7380
public boolean usesFolderAccess(Policy policy) {
7481
boolean readsFolder =
75-
policy.sources().stream().anyMatch(spec -> FOLDER_TYPE.equals(spec.type()));
82+
policy.sourceIds().stream()
83+
.map(sourceStore::get)
84+
.flatMap(Optional::stream)
85+
.anyMatch(source -> FOLDER_TYPE.equals(source.type()));
7686
boolean writesFolder =
7787
policy.output() != null && FOLDER_TYPE.equals(policy.output().type());
7888
return readsFolder || writesFolder;

app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
import java.util.List;
44
import java.util.Objects;
55

6-
import org.springframework.context.annotation.Profile;
6+
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
77
import org.springframework.stereotype.Component;
88

99
import lombok.RequiredArgsConstructor;
1010

1111
import stirling.software.common.model.ApplicationProperties;
1212
import stirling.software.common.service.UserServiceInterface;
1313
import stirling.software.proprietary.policy.model.Policy;
14+
import stirling.software.proprietary.policy.store.PolicyStore;
1415

1516
/**
1617
* Policies are scoped to a team: a user may view, run, edit, and delete only the policies belonging
@@ -22,7 +23,7 @@
2223
*/
2324
@Component
2425
@RequiredArgsConstructor
25-
@Profile("saas")
26+
@ConditionalOnBooleanProperty(name = "policies.enabled")
2627
public class PolicyAccessGuard {
2728

2829
private final UserServiceInterface userService;
@@ -47,13 +48,16 @@ public boolean canAccess(Policy policy) {
4748
return Objects.equals(policy.teamId(), policyManagementAuthority.currentUserTeamId());
4849
}
4950

50-
/** The subset of {@code policies} scoped to the current user's team. */
51-
public List<Policy> visible(List<Policy> policies) {
51+
/**
52+
* The policies visible to the caller: their whole team's, loaded scoped rather than fetched
53+
* globally and filtered, so on SaaS it never pulls another team's policies into memory. Login
54+
* disabled (single-user) returns everything.
55+
*/
56+
public List<Policy> visibleFrom(PolicyStore store) {
5257
if (!enforced()) {
53-
return policies;
58+
return store.all();
5459
}
55-
Long teamId = policyManagementAuthority.currentUserTeamId();
56-
return policies.stream().filter(policy -> Objects.equals(policy.teamId(), teamId)).toList();
60+
return store.findByTeam(policyManagementAuthority.currentUserTeamId());
5761
}
5862

5963
private boolean enforced() {

app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import java.util.List;
77
import java.util.Map;
88

9-
import org.springframework.context.annotation.Profile;
9+
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
1010
import org.springframework.core.io.FileSystemResource;
1111
import org.springframework.core.io.Resource;
1212
import org.springframework.http.HttpStatus;
@@ -53,7 +53,10 @@
5353
import stirling.software.proprietary.policy.model.PolicyRunStatus;
5454
import stirling.software.proprietary.policy.model.PolicyRunView;
5555
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
56+
import stirling.software.proprietary.policy.source.SourceAccessGuard;
57+
import stirling.software.proprietary.policy.source.SourceStore;
5658
import stirling.software.proprietary.policy.store.PolicyStore;
59+
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
5760

5861
/**
5962
* Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code
@@ -65,15 +68,18 @@
6568
@Hidden
6669
@RequiredArgsConstructor
6770
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
68-
@Profile("saas")
71+
@ConditionalOnBooleanProperty(name = "policies.enabled")
6972
public class PolicyController {
7073

7174
private final PolicyRunner policyRunner;
7275
private final PolicyRunRegistry runRegistry;
7376
private final PolicyStore policyStore;
77+
private final SourceStore sourceStore;
78+
private final SourceAccessGuard sourceAccessGuard;
7479
private final PolicyValidator policyValidator;
7580
private final PolicyAccessGuard policyAccessGuard;
7681
private final PolicyManagementAuthority policyManagementAuthority;
82+
private final PolicyTriggerManager policyTriggerManager;
7783
private final ApplicationProperties applicationProperties;
7884
private final TempFileManager tempFileManager;
7985
private final JobOwnershipService jobOwnershipService;
@@ -187,12 +193,33 @@ private boolean ownedByCurrentUser(String runId) {
187193
public ResponseEntity<Policy> savePolicy(@RequestBody Policy policy) {
188194
requirePolicyEditingAllowed();
189195
Policy owned = resolveOwnership(policy);
196+
requireAccessibleSources(owned);
190197
try {
191198
policyValidator.validate(owned);
192199
} catch (IllegalArgumentException e) {
193200
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
194201
}
195-
return ResponseEntity.ok(policyStore.save(owned));
202+
Policy saved = policyStore.save(owned);
203+
// Re-sync trigger registrations now so a new/changed folder-watch policy starts being
204+
// watched immediately instead of after the next reconcile sweep.
205+
policyTriggerManager.notifyPoliciesChanged();
206+
return ResponseEntity.ok(saved);
207+
}
208+
209+
/**
210+
* Every {@code sourceId} a policy references must resolve to a source in the caller's team, so
211+
* a client can neither reference a non-existent source nor reach across teams to use another
212+
* team's connection. A bad reference is a client error.
213+
*/
214+
private void requireAccessibleSources(Policy policy) {
215+
for (String sourceId : policy.sourceIds()) {
216+
boolean accessible =
217+
sourceStore.get(sourceId).filter(sourceAccessGuard::canAccess).isPresent();
218+
if (!accessible) {
219+
throw new ResponseStatusException(
220+
HttpStatus.BAD_REQUEST, "Unknown or inaccessible source: " + sourceId);
221+
}
222+
}
196223
}
197224

198225
/**
@@ -225,7 +252,7 @@ private static Policy withOwnerAndTeam(Policy policy, String owner, Long teamId)
225252
owner,
226253
policy.enabled(),
227254
policy.trigger(),
228-
policy.sources(),
255+
policy.sourceIds(),
229256
policy.steps(),
230257
policy.output(),
231258
teamId);
@@ -257,7 +284,7 @@ private void requirePolicyEditingAllowed() {
257284
summary = "List policies",
258285
description = "Lists the policies belonging to the caller's team.")
259286
public List<Policy> listPolicies() {
260-
return policyAccessGuard.visible(policyStore.all());
287+
return policyAccessGuard.visibleFrom(policyStore);
261288
}
262289

263290
@GetMapping("/{policyId}")
@@ -278,6 +305,9 @@ public ResponseEntity<Void> deletePolicy(@PathVariable String policyId) {
278305
boolean accessible =
279306
policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent();
280307
if (accessible && policyStore.delete(policyId)) {
308+
// Cancel any now-orphaned folder watch promptly rather than leaving the WatchKey open
309+
// until the next reconcile sweep.
310+
policyTriggerManager.notifyPoliciesChanged();
281311
return ResponseEntity.noContent().build();
282312
}
283313
return ResponseEntity.notFound().build();

app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import java.util.concurrent.ExecutorService;
1010

1111
import org.slf4j.MDC;
12-
import org.springframework.context.annotation.Profile;
12+
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
1313
import org.springframework.core.io.Resource;
1414
import org.springframework.http.ResponseEntity;
1515
import org.springframework.security.core.Authentication;
@@ -54,7 +54,7 @@
5454
@Slf4j
5555
@Service
5656
@RequiredArgsConstructor
57-
@Profile("saas")
57+
@ConditionalOnBooleanProperty(name = "policies.enabled")
5858
public class PolicyEngine {
5959

6060
// Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate

app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import java.util.concurrent.ScheduledExecutorService;
1010
import java.util.concurrent.TimeUnit;
1111

12-
import org.springframework.context.annotation.Profile;
12+
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
1313
import org.springframework.stereotype.Service;
1414

1515
import jakarta.annotation.PreDestroy;
@@ -29,7 +29,7 @@
2929
*/
3030
@Slf4j
3131
@Service
32-
@Profile("saas")
32+
@ConditionalOnBooleanProperty(name = "policies.enabled")
3333
public class PolicyRunRegistry {
3434

3535
private final Map<String, PolicyRun> runs = new ConcurrentHashMap<>();

0 commit comments

Comments
 (0)