Skip to content

Commit 884a205

Browse files
moesterheldtodvora
andauthored
Add manage permission for input types (#22468)
* add restrict_input_types config parameter * add `INPUT_TYPES_READ` permission and use it in `InputTypesResource` * switch from name to type * cl * added integration test for input permissions * added license * Extended input permission IT to input types * correct/add all necessary input types permissions * remove config parameter * add input types permission to InputsResource * add missing check * adjust IT * fix license header * fix InputsResourceMaskingPasswordsTest * add input type permission checks * add input type permission check * remove jetbrain annotation * add input type permission checks * add input type permission checks * add wait for roles cache in InputPermissionsIT * add migration to add input type permissions to existing input permission roles * fix license header * add upgrading notice * merge input type management permissions, remove read permission * update changelog, UPGRADING.md * cleanup --------- Co-authored-by: Tomas Dvorak <[email protected]>
1 parent 6af890b commit 884a205

File tree

19 files changed

+536
-58
lines changed

19 files changed

+536
-58
lines changed

UPGRADING.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@ Upgrading to Graylog 6.3.x
1313

1414
## Default Configuration Changes
1515

16-
- tbd
16+
- A permission `input_types:create` for creating input types has been introduced.
17+
18+
By granting only permissions for specific input types (e.g.
19+
`input_types:create:org.graylog2.inputs.misc.jsonpath.JsonPathInput`),
20+
users can be only allowed to manage inputs of specific types. Granting the permission without specifying input
21+
types (as shown above) will allow management of all input types.
22+
Existing roles and users are updated to automatically include the permissions for all input types if they contain a
23+
manage permission for inputs.
1724

1825
## Java API Changes
1926

changelog/unreleased/pr-22468.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
type = "a" # One of: a(dded), c(hanged), d(eprecated), r(emoved), f(ixed), s(ecurity)
2+
message = "Adds a permission to restrict creation of specified input types."
3+
4+
issues = ["graylog-plugin-enterprise#10477"]
5+
pulls = ["22468"]
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/*
2+
* Copyright (C) 2020 Graylog, Inc.
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the Server Side Public License, version 1,
6+
* as published by MongoDB, Inc.
7+
*
8+
* This program is distributed in the hope that it will be useful,
9+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
* Server Side Public License for more details.
12+
*
13+
* You should have received a copy of the Server Side Public License
14+
* along with this program. If not, see
15+
* <http://www.mongodb.com/licensing/server-side-public-license>.
16+
*/
17+
package org.graylog2.inputs;
18+
19+
import net.bytebuddy.utility.RandomString;
20+
import org.assertj.core.api.Assertions;
21+
import org.graylog.testing.completebackend.Lifecycle;
22+
import org.graylog.testing.completebackend.apis.GraylogApiResponse;
23+
import org.graylog.testing.completebackend.apis.GraylogApis;
24+
import org.graylog.testing.completebackend.apis.Users;
25+
import org.graylog.testing.containermatrix.SearchServer;
26+
import org.graylog.testing.containermatrix.annotations.ContainerMatrixTest;
27+
import org.graylog.testing.containermatrix.annotations.ContainerMatrixTestsConfiguration;
28+
import org.graylog2.shared.security.RestPermissions;
29+
import org.junit.jupiter.api.AfterAll;
30+
import org.junit.jupiter.api.BeforeAll;
31+
32+
import java.util.Arrays;
33+
import java.util.List;
34+
import java.util.Map;
35+
import java.util.Set;
36+
37+
import static org.hamcrest.CoreMatchers.equalTo;
38+
39+
@ContainerMatrixTestsConfiguration(serverLifecycle = Lifecycle.VM, searchVersions = SearchServer.DATANODE_DEV)
40+
public class InputPermissionsIT {
41+
42+
private final GraylogApis apis;
43+
44+
private GraylogApiResponse roleInputsReader;
45+
private GraylogApiResponse roleInputsCreator;
46+
private GraylogApiResponse roleRestrictedInputsCreator;
47+
48+
private Users.User inputsReader;
49+
private Users.User inputsCreator;
50+
private Users.User restrictedInputsCreator;
51+
52+
public InputPermissionsIT(GraylogApis apis) {
53+
this.apis = apis;
54+
}
55+
56+
@BeforeAll
57+
void setUp() {
58+
roleInputsReader = apis.roles().create("custom_inputs_reader", "inputs reader can only see inputs", Set.of(
59+
RestPermissions.INPUTS_READ
60+
), false);
61+
inputsReader = createUser("inputs.reader", roleInputsReader);
62+
63+
roleInputsCreator = apis.roles().create("custom_inputs_creator", "inputs creator can only create inputs", Set.of(
64+
RestPermissions.INPUTS_READ,
65+
RestPermissions.INPUTS_CREATE,
66+
RestPermissions.INPUT_TYPES_CREATE
67+
), false);
68+
inputsCreator = createUser("inputs.creator", roleInputsCreator);
69+
70+
roleRestrictedInputsCreator = apis.roles().create("custom_restricted_inputs_creator", "inputs creator can only create certain inputs", Set.of(
71+
RestPermissions.INPUTS_READ,
72+
RestPermissions.INPUTS_CREATE,
73+
RestPermissions.INPUT_TYPES_CREATE + ":org.graylog2.inputs.random.FakeHttpMessageInput",
74+
RestPermissions.INPUT_TYPES_CREATE + ":org.graylog2.inputs.gelf.tcp.GELFTCPInput"
75+
76+
), false);
77+
restrictedInputsCreator = createUser("restricted.inputs.creator", roleRestrictedInputsCreator);
78+
79+
waitForRolesCacheRefresh();
80+
81+
apis.users().createUser(inputsReader);
82+
apis.users().createUser(inputsCreator);
83+
apis.users().createUser(restrictedInputsCreator);
84+
}
85+
86+
/**
87+
* Roles are stored in mongodb, but the auth backend is refreshing those only once every second. If we trigger a call
88+
* before the role is refreshed, we may get weird results.
89+
* @see org.graylog2.security.InMemoryRolePermissionResolver
90+
*/
91+
private static void waitForRolesCacheRefresh() {
92+
try {
93+
// This is naive and wrong, but very simple to implement. Another approach would be to have a fingerprint of
94+
// roles cache, similarly to StreamRouterEngine and its fingerprint.
95+
Thread.sleep(1000);
96+
} catch (InterruptedException e) {
97+
throw new RuntimeException(e);
98+
}
99+
}
100+
101+
private Users.User createUser(String username, GraylogApiResponse... roles) {
102+
return new Users.User(username, RandomString.make(), "<Generated>", username,
103+
username + "@graylog", false, 30_0000, "Europe/Vienna",
104+
Arrays.stream(roles).map(role -> role.properJSONPath().read("name", String.class)).toList(), List.of());
105+
}
106+
107+
@AfterAll
108+
void tearDown() {
109+
apis.users().deleteUser(inputsReader.username());
110+
apis.users().deleteUser(inputsCreator.username());
111+
apis.users().deleteUser(restrictedInputsCreator.username());
112+
113+
apis.roles().delete(roleInputsReader.properJSONPath().read("name", String.class));
114+
apis.roles().delete(roleInputsCreator.properJSONPath().read("name", String.class));
115+
apis.roles().delete(roleRestrictedInputsCreator.properJSONPath().read("name", String.class));
116+
}
117+
118+
@ContainerMatrixTest
119+
void testPermittedInputCreationAndReading() {
120+
String inputId = apis.forUser(inputsCreator).inputs().createGlobalInput("testInput",
121+
"org.graylog2.inputs.random.FakeHttpMessageInput",
122+
Map.of("sleep", 30,
123+
"sleep_deviation", 30,
124+
"source", "example.org"));
125+
apis.waitFor(() ->
126+
apis.inputs().getInputState(inputId)
127+
.extract().body().jsonPath().get("state")
128+
.equals("RUNNING"),
129+
"Timed out waiting for HTTP Random Message Input to become available");
130+
131+
apis.forUser(inputsReader).inputs().getInput(inputId).assertThat().body("id", equalTo(inputId));
132+
133+
apis.inputs().deleteInput(inputId);
134+
135+
String inputId2 = apis.forUser(restrictedInputsCreator).inputs().createGlobalInput("testInput",
136+
"org.graylog2.inputs.random.FakeHttpMessageInput",
137+
Map.of("sleep", 30,
138+
"sleep_deviation", 30,
139+
"source", "example.org"));
140+
apis.waitFor(() ->
141+
apis.inputs().getInputState(inputId2)
142+
.extract().body().jsonPath().get("state")
143+
.equals("RUNNING"),
144+
"Timed out waiting for HTTP Random Message Input to become available");
145+
146+
apis.forUser(inputsReader).inputs().getInput(inputId2).assertThat().body("id", equalTo(inputId2));
147+
148+
apis.inputs().deleteInput(inputId2);
149+
}
150+
151+
@ContainerMatrixTest
152+
void testRestrictedInputCreationAndReading() {
153+
String inputId = apis.forUser(inputsCreator).inputs().createGlobalInput("testInput",
154+
"org.graylog2.inputs.misc.jsonpath.JsonPathInput",
155+
Map.of("target_url", "https://example.org",
156+
"interval", 10,
157+
"timeunit", "MINUTES",
158+
"path", "$.data",
159+
"source", "messagesource"));
160+
apis.waitFor(() ->
161+
apis.inputs().getInputState(inputId)
162+
.extract().body().jsonPath().get("state")
163+
.equals("RUNNING"),
164+
"Timed out waiting for Json Input to become available");
165+
166+
apis.forUser(inputsReader).inputs().getInput(inputId).assertThat().body("id", equalTo(inputId));
167+
168+
apis.inputs().deleteInput(inputId);
169+
170+
Assertions.assertThatThrownBy(() -> apis.forUser(restrictedInputsCreator).inputs().createGlobalInput("testInput",
171+
"org.graylog2.inputs.misc.jsonpath.JsonPathInput",
172+
Map.of("target_url", "https://example.org",
173+
"interval", 10,
174+
"timeunit", "MINUTES",
175+
"path", "$.data",
176+
"source", "messagesource")))
177+
.isInstanceOf(AssertionError.class)
178+
.hasMessageContaining("Expected status code <201> but was <403>");
179+
180+
}
181+
182+
@ContainerMatrixTest
183+
void testInputTypesRead() {
184+
final GraylogApiResponse inputTypesForReader = apis.forUser(inputsReader).inputs().getInputTypes();
185+
final Map<String, String> typesReader = inputTypesForReader.properJSONPath().read("types");
186+
Assertions.assertThat(typesReader).isEmpty();
187+
188+
final GraylogApiResponse inputTypesForRestrictedCreator = apis.forUser(restrictedInputsCreator).inputs().getInputTypes();
189+
final Map<String, String> typesRestricted = inputTypesForRestrictedCreator.properJSONPath().read("types");
190+
Assertions.assertThat(typesRestricted).containsOnlyKeys(
191+
"org.graylog2.inputs.random.FakeHttpMessageInput",
192+
"org.graylog2.inputs.gelf.tcp.GELFTCPInput"
193+
);
194+
195+
final GraylogApiResponse inputTypesForCreator = apis.forUser(inputsCreator).inputs().getInputTypes();
196+
final Map<String, String> typesCreator = inputTypesForCreator.properJSONPath().read("types");
197+
Assertions.assertThat(typesCreator).hasSizeGreaterThan(5);
198+
}
199+
}

graylog2-server/src/main/java/org/graylog/integrations/aws/resources/AWSResource.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,11 @@ public Response kinesisHealthCheck(@ApiParam(name = "JSON body", required = true
125125
@Timed
126126
@Path("/inputs")
127127
@ApiOperation(value = "Create a new AWS input.")
128-
@RequiresPermissions(RestPermissions.INPUTS_CREATE)
129128
@AuditEvent(type = IntegrationsAuditEventTypes.KINESIS_INPUT_CREATE)
129+
@RequiresPermissions({RestPermissions.INPUTS_CREATE, RestPermissions.INPUT_TYPES_CREATE + ":org.graylog.integrations.aws.inputs.AWSInput"})
130130
public Response create(@ApiParam @QueryParam("setup_wizard") @DefaultValue("false") boolean isSetupWizard,
131131
@ApiParam(name = "JSON body", required = true)
132132
@Valid @NotNull AWSInputCreateRequest saveRequest) throws Exception {
133-
134133
Input input = awsService.saveInput(saveRequest, getCurrentUser(), isSetupWizard);
135134
return Response.ok().entity(getInputSummary(input)).build();
136135
}

graylog2-server/src/main/java/org/graylog2/migrations/MigrationsModule.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,6 @@ protected void configure() {
7575
addMigration(V20250206105400_TokenManagementConfiguration.class);
7676
addMigration(V20250219134200_DefaultTTLForNewTokens.class);
7777
addMigration(V20250327120900_RenameDefaultPipeline.class);
78+
addMigration(V20250506090000_AddInputTypesPermissions.class);
7879
}
7980
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Copyright (C) 2020 Graylog, Inc.
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the Server Side Public License, version 1,
6+
* as published by MongoDB, Inc.
7+
*
8+
* This program is distributed in the hope that it will be useful,
9+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
* Server Side Public License for more details.
12+
*
13+
* You should have received a copy of the Server Side Public License
14+
* along with this program. If not, see
15+
* <http://www.mongodb.com/licensing/server-side-public-license>.
16+
*/
17+
package org.graylog2.migrations;
18+
19+
import jakarta.inject.Inject;
20+
import org.apache.commons.collections4.CollectionUtils;
21+
import org.graylog2.plugin.cluster.ClusterConfigService;
22+
import org.graylog2.plugin.database.ValidationException;
23+
import org.graylog2.shared.security.RestPermissions;
24+
import org.graylog2.shared.users.UserService;
25+
import org.graylog2.users.RoleService;
26+
import org.slf4j.Logger;
27+
import org.slf4j.LoggerFactory;
28+
29+
import java.time.ZonedDateTime;
30+
import java.util.List;
31+
import java.util.Set;
32+
33+
public class V20250506090000_AddInputTypesPermissions extends Migration {
34+
35+
private static final Logger LOG = LoggerFactory.getLogger(V20250506090000_AddInputTypesPermissions.class);
36+
37+
private final ClusterConfigService clusterConfigService;
38+
private final RoleService roleService;
39+
private final UserService userService;
40+
41+
@Inject
42+
public V20250506090000_AddInputTypesPermissions(final ClusterConfigService clusterConfigService,
43+
RoleService roleService, UserService userService) {
44+
this.clusterConfigService = clusterConfigService;
45+
this.roleService = roleService;
46+
this.userService = userService;
47+
}
48+
49+
@Override
50+
public ZonedDateTime createdAt() {
51+
return ZonedDateTime.parse("2025-05-06T09:00:00Z");
52+
}
53+
54+
@Override
55+
public void upgrade() {
56+
if (clusterConfigService.get(V20250506090000_AddInputTypesPermissions.MigrationCompleted.class) != null) {
57+
LOG.debug("Migration already completed.");
58+
return;
59+
}
60+
LOG.debug("Starting migration to add input types permissions.");
61+
roleService.loadAll().stream()
62+
.filter(role -> !role.isReadOnly())
63+
.filter(role -> CollectionUtils.containsAny(role.getPermissions(),
64+
RestPermissions.INPUTS_CHANGESTATE, RestPermissions.INPUTS_CREATE,
65+
RestPermissions.INPUTS_EDIT, RestPermissions.INPUTS_TERMINATE))
66+
.peek(role -> {
67+
Set<String> permissions = role.getPermissions();
68+
permissions.add(RestPermissions.INPUT_TYPES_CREATE);
69+
role.setPermissions(permissions);
70+
}).forEach(role -> {
71+
LOG.info("Updating role {} to include input type permission", role.getName());
72+
try {
73+
roleService.save(role);
74+
} catch (ValidationException e) {
75+
LOG.error("Error updating role.", e);
76+
}
77+
});
78+
79+
userService.loadAll().stream()
80+
.filter(user -> CollectionUtils.containsAny(user.getPermissions(),
81+
RestPermissions.INPUTS_CHANGESTATE, RestPermissions.INPUTS_CREATE,
82+
RestPermissions.INPUTS_EDIT, RestPermissions.INPUTS_TERMINATE))
83+
.peek(user -> {
84+
List<String> permissions = user.getPermissions();
85+
permissions.add(RestPermissions.INPUT_TYPES_CREATE);
86+
user.setPermissions(permissions);
87+
}).forEach(user -> {
88+
LOG.info("Updating user {} to include individual input type permission", user.getName());
89+
try {
90+
userService.save(user);
91+
} catch (ValidationException e) {
92+
LOG.error("Error updating user.", e);
93+
}
94+
});
95+
96+
clusterConfigService.write(new V20250506090000_AddInputTypesPermissions.MigrationCompleted());
97+
}
98+
99+
public record MigrationCompleted() {
100+
}
101+
}

graylog2-server/src/main/java/org/graylog2/rest/resources/system/inputs/AbstractInputsResource.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ public AbstractInputsResource(Map<String, InputDescription> availableInputs) {
4747
protected InputSummary getInputSummary(Input input) {
4848
final InputDescription inputDescription = this.availableInputs.get(input.getType());
4949
final ConfigurationRequest configurationRequest = inputDescription != null ? inputDescription.getConfigurationRequest() : null;
50-
final Map<String, Object> configuration = isPermitted(RestPermissions.INPUTS_EDIT, input.getId()) ?
50+
// remove after sharing inputs implemented (input types check)
51+
final Map<String, Object> configuration = isPermitted(RestPermissions.INPUTS_EDIT, input.getId()) && isPermitted(RestPermissions.INPUT_TYPES_CREATE, input.getType()) ?
5152
input.getConfiguration() : maskPasswordsInConfiguration(input.getConfiguration(), configurationRequest);
5253
return InputSummary.create(input.getTitle(),
5354
input.isGlobal(),

0 commit comments

Comments
 (0)