-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathAccessControlService.java
More file actions
360 lines (327 loc) · 15.7 KB
/
Copy pathAccessControlService.java
File metadata and controls
360 lines (327 loc) · 15.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
package com.dbaagent.service.security;
import com.dbaagent.model.AnalysisHistory;
import com.dbaagent.model.Chat;
import com.dbaagent.model.ChatFeedback;
import com.dbaagent.model.EffectiveConnectionAccess;
import com.dbaagent.model.Permission;
import com.dbaagent.model.Role;
import com.dbaagent.repository.AnalysisHistoryRepository;
import com.dbaagent.repository.ChatFeedbackRepository;
import com.dbaagent.repository.ChatRepository;
import com.dbaagent.security.ImpersonationContext;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@Service
@RequiredArgsConstructor
public class AccessControlService {
/**
* Identity attributed to actions taken while {@code security.auth.enabled} is false.
* Matches the owner fallback used when a connection is saved without a principal, so a
* dev-mode install does not end up with records split across two synthetic owners.
*/
private static final String LOCAL_FALLBACK_USERNAME = "admin";
@Value("${security.auth.enabled:true}")
private boolean authEnabled;
private final ConnectionAccessService connectionAccessService;
private final ChatRepository chatRepository;
private final ChatFeedbackRepository chatFeedbackRepository;
private final AnalysisHistoryRepository analysisHistoryRepository;
public void assertCanAccessConnection(String connectionId) {
assertCanUseConnection(connectionId);
}
public void assertCanUseConnection(String connectionId) {
assertAccess(connectionId, EffectiveConnectionAccess::canUseConnection, "Access denied for this connection");
}
public void assertCanUseChatEditor(String connectionId) {
assertAccess(connectionId, EffectiveConnectionAccess::canUseChatEditor, "Chat and editor access denied for this connection");
}
public void assertCanManageConnectionContent(String connectionId) {
assertAccess(connectionId, EffectiveConnectionAccess::canManageContent, "Content access denied for this connection");
}
/**
* Read-only access to connection content (brain context, knowledge,
* analytics, recommendations). Any user with connection access (CHAT_EDITOR
* or higher) can read; write paths still require canManageContent.
*/
public void assertCanReadConnectionContent(String connectionId) {
assertAccess(connectionId, EffectiveConnectionAccess::canReadContent, "Read access denied for this connection");
}
public void assertCanManageConnectionConfig(String connectionId) {
assertAccess(connectionId, EffectiveConnectionAccess::canManageConfig, "Configuration access denied for this connection");
}
/**
* As {@link #assertCanReadConnectionContent}, but reports 404 instead of 403 — for
* endpoints keyed on a row id rather than a connection id.
*
* <p>Returning 403 for a row the caller may not touch and 404 for one that does not
* exist tells the caller which ids are real. That is an enumeration primitive, and
* `query_performance_regression.id` is a sequential {@code Long}, so walking it is
* trivial. Collapsing both to 404 means "no such row, as far as you are concerned",
* which is the same answer {@code DashboardWorkspaceService.assertCanReadDashboard}
* already gives for a dashboard outside the caller's workspace.
*
* <p>Use this only where the caller supplied an <em>opaque row id</em>. Endpoints that
* take a {@code connectionId} directly should keep 403: the caller already knows the
* connection exists (they typed its id), so hiding it buys nothing and an actionable
* "access denied" is the better answer.
*
* @param entity human-readable name for the 404 message, e.g. {@code "Alert"}
*/
public void assertCanReadConnectionContentOrNotFound(String connectionId, String entity) {
assertOrNotFound(connectionId, EffectiveConnectionAccess::canReadContent, entity);
}
/** Write-side counterpart to {@link #assertCanReadConnectionContentOrNotFound}. */
public void assertCanManageConnectionContentOrNotFound(String connectionId, String entity) {
assertOrNotFound(connectionId, EffectiveConnectionAccess::canManageContent, entity);
}
private void assertOrNotFound(
String connectionId,
java.util.function.Predicate<EffectiveConnectionAccess> predicate,
String entity
) {
ConnectionAccessService.ResolvedConnectionAccess access;
try {
access = resolveCurrentUserAccess(connectionId);
} catch (ResponseStatusException e) {
// An unresolvable connection, or an unauthenticated caller, must look the same
// as a row that isn't there — otherwise the distinction leaks back in here.
throw new ResponseStatusException(NOT_FOUND, entity + " not found");
}
if (!predicate.test(access.getEffectiveAccess())) {
throw new ResponseStatusException(NOT_FOUND, entity + " not found");
}
}
public ConnectionAccessService.ResolvedConnectionAccess resolveCurrentUserAccess(String connectionId) {
if (!authEnabled && !ImpersonationContext.isActive()) {
try {
return connectionAccessService.resolveAccess(connectionId, null, true);
} catch (RuntimeException e) {
throw new ResponseStatusException(NOT_FOUND, "Connection not found");
}
}
Authentication authentication = currentAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
throw new ResponseStatusException(FORBIDDEN, "Access denied");
}
try {
return connectionAccessService.resolveAccess(connectionId, authentication.getName(), isCurrentUserAdmin());
} catch (RuntimeException e) {
throw new ResponseStatusException(NOT_FOUND, "Connection not found");
}
}
public void assertCanAccessChat(String chatId) {
Chat chat = findAccessibleChat(chatId);
assertCanUseChatEditor(chat.getConnectionId());
}
public void assertChatBelongsToConnection(String chatId, String connectionId) {
if (chatId == null || chatId.isBlank()) {
return;
}
Chat chat = findAccessibleChat(chatId);
if (!connectionId.equals(chat.getConnectionId())) {
throw new ResponseStatusException(FORBIDDEN, "Chat does not belong to the selected connection");
}
assertCanUseChatEditor(connectionId);
}
/**
* As {@link #assertChatBelongsToConnection}, but tolerates a chat that does not
* exist yet.
*
* <p>Clients generate a chat id when the user opens a conversation and send it with
* the first message, before anything is persisted. Treating that as "Chat not found"
* rejected the opening message of every new conversation — which is what this
* variant existed to prevent, except its body was identical to the strict one, so
* the name promised leniency the code never implemented.
*
* <p>The existence check must come first, and must not be folded into
* {@code findAccessibleChatIfPresent}: that method looks chats up by id AND owner,
* so it returns empty both for "no such chat" and for "someone else's chat".
* Collapsing the two would turn this into a probe — pass another user's chat id, get
* an empty result, sail through. So: absent means there is nothing to protect and we
* allow; present means the strict rules apply, unchanged.
*/
public void assertChatBelongsToConnectionIfPresent(String chatId, String connectionId) {
if (chatId == null || chatId.isBlank()) {
return;
}
if (!chatRepository.existsById(chatId)) {
return;
}
assertChatBelongsToConnection(chatId, connectionId);
}
public void assertCanAccessFeedback(String feedbackId) {
ChatFeedback feedback = chatFeedbackRepository.findById(feedbackId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Feedback not found"));
if (feedback.getChatId() != null && !feedback.getChatId().isBlank()) {
assertCanAccessChat(feedback.getChatId());
return;
}
assertCanUseChatEditor(feedback.getConnectionId());
}
public void assertCanAccessAnalysisHistory(String historyId) {
AnalysisHistory history = analysisHistoryRepository.findById(historyId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Analysis history not found"));
assertCanUseChatEditor(history.getConnectionId());
}
public String getCurrentUsername() {
Authentication authentication = currentAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return null;
}
return authentication.getName();
}
/**
* The caller's username, or the local fallback identity when authentication is off.
*
* <p>Every other check in this class honours {@code security.auth.enabled} — with auth
* off, {@link #resolveCurrentUserAccess} hands back ADMIN and {@link #isCurrentUserAdmin}
* returns true. This method did not, so it threw 403 "Access denied" at all 17 of its
* call sites the moment anyone actually ran with auth disabled, {@code ChatController}
* included: the documented dev-mode bypass switched chat off instead of opening it.
* A bypass has to be coherent or it is not a bypass.
*
* <p>{@code "admin"} matches the owner fallback already used when a connection is saved
* without a principal, so records created in dev mode carry one consistent owner.
*
* <p>With auth enabled — every real deployment — behaviour is unchanged: no principal
* means 403.
*/
public String requireCurrentUsername() {
String username = getCurrentUsername();
if (username == null || username.isBlank()) {
if (!authEnabled) {
return LOCAL_FALLBACK_USERNAME;
}
throw new ResponseStatusException(FORBIDDEN, "Access denied");
}
return username;
}
/**
* Assert the caller may create a database connection.
*
* <p>Creating a connection is not scoped to an existing connection, so none of the
* {@code assertCanManage*Connection*} checks apply — there is no id to resolve
* access against yet. Without this, {@code POST /connections} had no authorization
* at all: a Developer or Data Engineer could create, then edit and delete, their own
* connection (verified live against a running install — the row persisted with
* {@code owner_username = analyst}). Hiding the Connections button only hid the
* button.
*
* <p>Permission-based rather than {@code isCurrentUserAdmin()} so DBA — which holds
* MANAGE_CONNECTIONS by design — keeps working, and so an admin-defined custom role
* granting that permission behaves consistently.
*/
public void assertCanManageConnections() {
if (!authEnabled) {
return;
}
if (!hasPermission(Permission.MANAGE_CONNECTIONS)) {
throw new ResponseStatusException(FORBIDDEN, "You do not have permission to manage connections");
}
}
/** Assert the caller holds a permission, with a caller-supplied message. */
public void assertHasPermission(Permission permission, String message) {
if (!authEnabled) {
return;
}
if (!hasPermission(permission)) {
throw new ResponseStatusException(FORBIDDEN, message);
}
}
/**
* Whether the current principal carries a permission authority.
*
* <p>{@code CustomUserDetailsService} stamps every effective permission onto the
* authentication as a plain authority alongside {@code ROLE_<code>}, so this reads
* the already-resolved set (overrides and custom roles included) without a lookup.
*/
public boolean hasPermission(Permission permission) {
if (permission == null) {
return false;
}
if (isCurrentUserAdmin()) {
return true;
}
Authentication authentication = currentAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return false;
}
return authentication.getAuthorities().stream()
.anyMatch(authority -> permission.name().equals(authority.getAuthority()));
}
public boolean isCurrentUserAdmin() {
if (ImpersonationContext.isActive()) {
return ImpersonationContext.current()
.map(state -> state.target() != null && state.target().isAdmin())
.orElse(false);
}
if (!authEnabled) {
return true;
}
Authentication authentication = currentAuthentication();
return authentication != null && authentication.getAuthorities().stream()
.anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority()));
}
/**
* Whether the current principal may run confirmed DDL/DML on SQL surfaces
* (Editor / MCP). Built-in ADMIN and DBA only — not custom roles, and not
* DEVELOPER / DATA_ENGINEER. Distinct from {@link #isCurrentUserAdmin()}:
* DBA must not receive MANAGE_USERS or other admin-only product controls.
*/
public boolean currentUserMayMutateSql() {
if (ImpersonationContext.isActive()) {
return ImpersonationContext.current()
.map(state -> {
if (state.target() == null) {
return false;
}
Role role = state.target().getRoleEnum();
return role == Role.ADMIN || role == Role.DBA;
})
.orElse(false);
}
if (!authEnabled) {
return true;
}
Authentication authentication = currentAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return false;
}
return authentication.getAuthorities().stream()
.anyMatch(authority -> {
String value = authority.getAuthority();
return "ROLE_ADMIN".equals(value) || "ROLE_DBA".equals(value);
});
}
private Authentication currentAuthentication() {
return SecurityContextHolder.getContext().getAuthentication();
}
private Chat findAccessibleChat(String chatId) {
return findAccessibleChatIfPresent(chatId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Chat not found"));
}
private Optional<Chat> findAccessibleChatIfPresent(String chatId) {
if (!authEnabled && !ImpersonationContext.isActive()) {
return chatRepository.findById(chatId);
}
String username = requireCurrentUsername();
return chatRepository.findByIdAndOwnerUsernameIgnoreCase(chatId, username);
}
private void assertAccess(
String connectionId,
java.util.function.Predicate<EffectiveConnectionAccess> predicate,
String message
) {
ConnectionAccessService.ResolvedConnectionAccess access = resolveCurrentUserAccess(connectionId);
if (!predicate.test(access.getEffectiveAccess())) {
throw new ResponseStatusException(FORBIDDEN, message);
}
}
}