Skip to content

Commit 1814d11

Browse files
authored
Read-only mode and Spring Security example (#129)
* Read-only mode and Spring Security example setup * introduce a read-only mode in which you can only consult tasks but not alter them: * controller separation * config for frontend * ui changes to hide buttons * provide Spring Security example * option to alter frontend config at runtime * test with sample config and tests * bump SB and db-scheduler versions * Read-only mode and Spring Security example setup * attempt to fix test failures by creating different tasks for deletion
1 parent f378bdb commit 1814d11

File tree

27 files changed

+527
-59
lines changed

27 files changed

+527
-59
lines changed

.github/workflows/tests.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ jobs:
88
runs-on: ubuntu-latest
99
strategy:
1010
matrix:
11-
spring-boot: [ '3.3.7', '3.4.1' ]
11+
spring-boot: [ '3.3.8', '3.4.2' ]
1212

1313
steps:
1414
- name: Checkout repo

README.md

+54-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ dashboard for monitoring and basic administration of tasks.
4141
<dependency>
4242
<groupId>no.bekk.db-scheduler-ui</groupId>
4343
<artifactId>db-scheduler-ui-starter</artifactId>
44-
<version>1.0.1</version>
44+
<version>4.0.0</version>
4545
</dependency>
4646
```
4747

@@ -94,6 +94,59 @@ If you for some reason want to hide the task data you can set this to false. def
9494
db-scheduler-ui.task-data=false
9595
```
9696

97+
Or if you want a _read-only_ mode (in which tasks cannot be manually run, deleted or scheduled) set `read-only` to `true`, defaults to `false`
98+
99+
````
100+
db-scheduler-ui.read-only=true
101+
````
102+
103+
## Security
104+
105+
In case you want to secure db-scheduler-ui you can use [Spring Security](https://spring.io/projects/spring-security), you should secure the paths `/db-scheduler` and `/db-scheduler-api`.
106+
107+
In a more advanced scenario, you could assign an _admin role_ and a _read-only user_ role.
108+
An example _filter chain_ with basic security:
109+
```java
110+
@Bean
111+
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
112+
return http
113+
.csrf(CsrfConfigurer::disable)
114+
.authorizeHttpRequests(
115+
authz ->
116+
authz
117+
// protect the UI
118+
.requestMatchers("/db-scheduler/**").hasAnyRole("ADMIN", "USER")
119+
// allow read access to the API for both users and admins
120+
.requestMatchers(HttpMethod.GET, "/db-scheduler-api/**").hasAnyRole("ADMIN", "USER")
121+
// only admins can delete tasks, alter scheduling, ...
122+
.requestMatchers(HttpMethod.POST, "/db-scheduler-api/**").hasAnyRole("ADMIN")
123+
// other application specific security
124+
.anyRequest().permitAll())
125+
.httpBasic(withDefaults())
126+
.build();
127+
}
128+
```
129+
additionally you might want to hide delete, run, ... buttons in the UI. To achieve this, declare the following bean:
130+
```java
131+
@Bean
132+
ConfigController configController(DbSchedulerUiProperties properties) {
133+
return new ConfigController(properties.isHistory(), readOnly(properties));
134+
}
135+
136+
private Supplier<Boolean> readOnly(DbSchedulerUiProperties properties) {
137+
// either global readonly mode is active or user has no admin rights
138+
return () -> properties.isReadOnly() || !isAdmin();
139+
}
140+
141+
private boolean isAdmin() {
142+
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
143+
if (auth == null) {
144+
return false;
145+
}
146+
return auth.getAuthorities().stream().anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));
147+
}
148+
```
149+
97150
## Contributing
98151

99152
Feel free to create pull requests if there are features or improvements you want to add.

db-scheduler-ui-frontend/src/components/input/DotButton.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { CalendarIcon, DeleteIcon, InfoOutlineIcon } from '@chakra-ui/icons';
3232
import { IoEllipsisVerticalIcon } from '../../assets/icons';
3333
import { useNavigate } from 'react-router-dom';
3434
import { ScheduleRunAlert } from './ScheduleRunAlert';
35+
import { getReadonly } from 'src/utils/config';
3536

3637
interface TaskProps {
3738
taskName: string;
@@ -65,6 +66,7 @@ export const DotButton: React.FC<TaskProps> = ({
6566

6667
<MenuList padding={0}>
6768
<MenuItem
69+
display={getReadonly() ? 'none' : 'unset'}
6870
rounded={6}
6971
minBlockSize={10}
7072
onClick={(event) => {
@@ -87,6 +89,7 @@ export const DotButton: React.FC<TaskProps> = ({
8789
See history for task
8890
</MenuItem>
8991
<MenuItem
92+
display={getReadonly() ? 'none' : 'unset'}
9093
rounded={6}
9194
minBlockSize={10}
9295
onClick={(event) => {

db-scheduler-ui-frontend/src/components/scheduled/TaskAccordionButton.tsx

+6-1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { NumberCircleGroup } from 'src/components/common/NumberCircleGroup';
3030
import { AttachmentIcon } from '@chakra-ui/icons';
3131
import { determineStatus, isStatus } from 'src/utils/determineStatus';
3232
import colors from 'src/styles/colors';
33+
import { getReadonly } from 'src/utils/config';
3334

3435
interface TaskAccordionButtonProps extends Task {
3536
refetch: () => void;
@@ -128,7 +129,11 @@ export const TaskAccordionButton: React.FC<TaskAccordionButtonProps> = (
128129
}
129130
w={150}
130131
>
131-
<TaskRunButton {...props} refetch={refetch} />
132+
<TaskRunButton
133+
{...props}
134+
refetch={refetch}
135+
style={{ visibility: getReadonly() ? 'hidden' : 'visible' }}
136+
/>
132137
<DotButton
133138
taskName={taskName}
134139
taskInstance={taskInstance[0]}

db-scheduler-ui-frontend/src/services/deleteTask.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ const deleteTask = async (id: string, name: string) => {
2323
},
2424
);
2525

26-
if (!response.ok) {
26+
if (response.status == 401) {
27+
document.location.href = '/db-scheduler';
28+
} else if (!response.ok) {
2729
throw new Error(`Error executing task. Status: ${response.statusText}`);
2830
}
2931
};

db-scheduler-ui-frontend/src/services/getLogs.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ export const getLogs = async (
6262
},
6363
});
6464

65-
if (!response.ok) {
65+
if (response.status == 401) {
66+
document.location.href = '/db-scheduler';
67+
} else if (!response.ok) {
6668
throw new Error(`Error fetching logs. Status: ${response.statusText}`);
6769
}
6870

db-scheduler-ui-frontend/src/services/getTask.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ export const getTask = async (
6060
},
6161
});
6262

63-
if (!response.ok) {
63+
if (response.status == 401) {
64+
document.location.href = '/db-scheduler';
65+
} else if (!response.ok) {
6466
throw new Error(`Error fetching tasks. Status: ${response.statusText}`);
6567
}
6668

db-scheduler-ui-frontend/src/services/getTasks.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ export const getTasks = async (
5858
},
5959
});
6060

61-
if (!response.ok) {
61+
if (response.status == 401) {
62+
document.location.href = '/db-scheduler';
63+
} else if (!response.ok) {
6264
throw new Error(`Error fetching tasks. Status: ${response.statusText}`);
6365
}
6466

db-scheduler-ui-frontend/src/services/pollLogs.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ export const pollLogs = async (
6060
},
6161
});
6262

63-
if (!response.ok) {
63+
if (response.status == 401) {
64+
document.location.href = '/db-scheduler';
65+
} else if (!response.ok) {
6466
throw new Error(`Error polling tasks. Status: ${response.statusText}`);
6567
}
6668

db-scheduler-ui-frontend/src/services/pollTasks.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@ export const pollTasks = async (
6060
'Content-Type': 'application/json',
6161
},
6262
});
63-
64-
if (!response.ok) {
63+
if (response.status == 401) {
64+
document.location.href = '/db-scheduler';
65+
} else if (!response.ok) {
6566
throw new Error(`Error polling tasks. Status: ${response.statusText}`);
6667
}
6768

db-scheduler-ui-frontend/src/services/runTask.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ const runTask = async (id: string, name: string, scheduleTime?:Date) => {
3434
},
3535
);
3636

37-
if (!response.ok) {
37+
if (response.status == 401) {
38+
document.location.href = '/db-scheduler';
39+
} else if (!response.ok) {
3840
throw new Error(`Error executing task. Status: ${response.statusText}`);
3941
}
4042
};

db-scheduler-ui-frontend/src/services/runTaskGroup.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ const runTaskGroup = async (name: string, onlyFailed: boolean) => {
2323
},
2424
);
2525

26-
if (!response.ok) {
26+
if (response.status == 401) {
27+
document.location.href = '/db-scheduler';
28+
} else if (!response.ok) {
2729
throw new Error(`Error executing task. Status: ${response.statusText}`);
2830
}
2931
};

db-scheduler-ui-frontend/src/utils/config.ts

+3
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,7 @@ const config = await fetch('/db-scheduler-api/config').then((res) =>
1919
const showHistory =
2020
'showHistory' in config ? Boolean(config.showHistory) : false;
2121

22+
const readOnly = 'readOnly' in config ? Boolean(config.readOnly) : false;
23+
2224
export const getShowHistory = (): boolean => showHistory;
25+
export const getReadonly = (): boolean => readOnly;

db-scheduler-ui-starter/src/main/java/no/bekk/dbscheduler/uistarter/autoconfigure/UiApiAutoConfiguration.java

+13-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import no.bekk.dbscheduler.ui.controller.ConfigController;
2121
import no.bekk.dbscheduler.ui.controller.LogController;
2222
import no.bekk.dbscheduler.ui.controller.SpaFallbackMvc;
23+
import no.bekk.dbscheduler.ui.controller.TaskAdminController;
2324
import no.bekk.dbscheduler.ui.controller.TaskController;
2425
import no.bekk.dbscheduler.ui.service.LogLogic;
2526
import no.bekk.dbscheduler.ui.service.TaskLogic;
@@ -88,6 +89,17 @@ LogLogic logLogic(
8889
logLimit);
8990
}
9091

92+
@Bean
93+
@ConditionalOnMissingBean
94+
@ConditionalOnProperty(
95+
prefix = "db-scheduler-ui",
96+
name = "read-only",
97+
havingValue = "false",
98+
matchIfMissing = true)
99+
TaskAdminController taskAdminController(TaskLogic taskLogic) {
100+
return new TaskAdminController(taskLogic);
101+
}
102+
91103
@Bean
92104
@ConditionalOnMissingBean
93105
TaskController taskController(TaskLogic taskLogic) {
@@ -125,6 +137,6 @@ public RouterFunction<ServerResponse> dbSchedulerRouter(
125137
@Bean
126138
@ConditionalOnMissingBean
127139
ConfigController configController(DbSchedulerUiProperties properties) {
128-
return new ConfigController(properties.isHistory());
140+
return new ConfigController(properties.isHistory(), properties::isReadOnly);
129141
}
130142
}

db-scheduler-ui-starter/src/main/java/no/bekk/dbscheduler/uistarter/config/DbSchedulerUiProperties.java

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
public class DbSchedulerUiProperties {
2424

2525
private boolean enabled = true;
26+
private boolean readOnly = false;
2627
private boolean taskData = true;
2728
private boolean history = false;
2829
private int logLimit = 0;

db-scheduler-ui/src/main/java/no/bekk/dbscheduler/ui/controller/ConfigController.java

+5-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
*/
1414
package no.bekk.dbscheduler.ui.controller;
1515

16+
import java.util.function.Supplier;
1617
import no.bekk.dbscheduler.ui.model.ConfigResponse;
1718
import org.springframework.web.bind.annotation.CrossOrigin;
1819
import org.springframework.web.bind.annotation.GetMapping;
@@ -25,13 +26,15 @@
2526
public class ConfigController {
2627

2728
private final boolean showHistory;
29+
private final Supplier<Boolean> readOnly;
2830

29-
public ConfigController(boolean showHistory) {
31+
public ConfigController(boolean showHistory, Supplier<Boolean> readOnly) {
3032
this.showHistory = showHistory;
33+
this.readOnly = readOnly;
3134
}
3235

3336
@GetMapping
3437
public ConfigResponse getConfig() {
35-
return new ConfigResponse(showHistory);
38+
return new ConfigResponse(showHistory, readOnly.get());
3639
}
3740
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright (C) Bekk
3+
*
4+
* <p>Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
5+
* except in compliance with the License. You may obtain a copy of the License at
6+
*
7+
* <p>http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* <p>Unless required by applicable law or agreed to in writing, software distributed under the
10+
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11+
* express or implied. See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
package no.bekk.dbscheduler.ui.controller;
15+
16+
import java.time.Instant;
17+
import no.bekk.dbscheduler.ui.service.TaskLogic;
18+
import org.springframework.beans.factory.annotation.Autowired;
19+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
20+
import org.springframework.web.bind.annotation.CrossOrigin;
21+
import org.springframework.web.bind.annotation.PostMapping;
22+
import org.springframework.web.bind.annotation.RequestMapping;
23+
import org.springframework.web.bind.annotation.RequestParam;
24+
import org.springframework.web.bind.annotation.RestController;
25+
26+
@RestController
27+
@CrossOrigin
28+
@RequestMapping("/db-scheduler-api/tasks")
29+
@ConditionalOnProperty(
30+
prefix = "db-scheduler-ui",
31+
name = "read-only",
32+
havingValue = "false",
33+
matchIfMissing = true)
34+
public class TaskAdminController {
35+
36+
private final TaskLogic taskLogic;
37+
38+
@Autowired
39+
public TaskAdminController(TaskLogic taskLogic) {
40+
this.taskLogic = taskLogic;
41+
}
42+
43+
@PostMapping("/rerun")
44+
public void runNow(
45+
@RequestParam String id, @RequestParam String name, @RequestParam Instant scheduleTime) {
46+
taskLogic.runTaskNow(id, name, scheduleTime);
47+
}
48+
49+
@PostMapping("/rerunGroup")
50+
public void runAllNow(@RequestParam String name, @RequestParam boolean onlyFailed) {
51+
taskLogic.runTaskGroupNow(name, onlyFailed);
52+
}
53+
54+
@PostMapping("/delete")
55+
public void deleteTaskNow(@RequestParam String id, @RequestParam String name) {
56+
taskLogic.deleteTask(id, name);
57+
}
58+
}

db-scheduler-ui/src/main/java/no/bekk/dbscheduler/ui/controller/TaskController.java

+4-18
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@
1313
*/
1414
package no.bekk.dbscheduler.ui.controller;
1515

16-
import java.time.Instant;
1716
import no.bekk.dbscheduler.ui.model.GetTasksResponse;
1817
import no.bekk.dbscheduler.ui.model.PollResponse;
1918
import no.bekk.dbscheduler.ui.model.TaskDetailsRequestParams;
2019
import no.bekk.dbscheduler.ui.model.TaskRequestParams;
2120
import no.bekk.dbscheduler.ui.service.TaskLogic;
2221
import org.springframework.beans.factory.annotation.Autowired;
23-
import org.springframework.web.bind.annotation.*;
22+
import org.springframework.web.bind.annotation.CrossOrigin;
23+
import org.springframework.web.bind.annotation.GetMapping;
24+
import org.springframework.web.bind.annotation.RequestMapping;
25+
import org.springframework.web.bind.annotation.RestController;
2426

2527
@RestController
2628
@CrossOrigin
@@ -47,20 +49,4 @@ public GetTasksResponse getTaskDetails(TaskDetailsRequestParams params) {
4749
public PollResponse pollForUpdates(TaskDetailsRequestParams params) {
4850
return taskLogic.pollTasks(params);
4951
}
50-
51-
@PostMapping("/rerun")
52-
public void runNow(
53-
@RequestParam String id, @RequestParam String name, @RequestParam Instant scheduleTime) {
54-
taskLogic.runTaskNow(id, name, scheduleTime);
55-
}
56-
57-
@PostMapping("/rerunGroup")
58-
public void runAllNow(@RequestParam String name, @RequestParam boolean onlyFailed) {
59-
taskLogic.runTaskGroupNow(name, onlyFailed);
60-
}
61-
62-
@PostMapping("/delete")
63-
public void deleteTaskNow(@RequestParam String id, @RequestParam String name) {
64-
taskLogic.deleteTask(id, name);
65-
}
6652
}

db-scheduler-ui/src/main/java/no/bekk/dbscheduler/ui/model/ConfigResponse.java

+1
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@
2222
@AllArgsConstructor
2323
public class ConfigResponse {
2424
private boolean showHistory;
25+
private boolean readOnly;
2526
}

0 commit comments

Comments
 (0)