Skip to content

Commit 1eeb440

Browse files
authored
fix(incidents): authorize field incidents against the parent entity (#19405)
1 parent 64c5f05 commit 1eeb440

6 files changed

Lines changed: 203 additions & 39 deletions

File tree

‎datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/IncidentUtils.java‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@
1919
import com.linkedin.incident.IncidentStage;
2020
import com.linkedin.incident.IncidentState;
2121
import com.linkedin.incident.IncidentStatus;
22+
import com.linkedin.metadata.Constants;
2223
import com.linkedin.metadata.authorization.PoliciesConfig;
2324
import com.linkedin.metadata.service.IncidentInfoPatch;
2425
import com.linkedin.metadata.service.IncidentInfoUpsert;
26+
import com.linkedin.metadata.utils.SchemaFieldUtils;
27+
import com.linkedin.util.Pair;
2528
import java.util.List;
2629
import java.util.Objects;
2730
import java.util.stream.Collectors;
@@ -45,6 +48,32 @@ public static List<Urn> stringsToUrns(List<String> urns) {
4548
.toList();
4649
}
4750

51+
/**
52+
* Resolves the URN that an incident edit is authorized against.
53+
*
54+
* <p>For a schemaField this is the parent entity encoded in the field URN, not the field itself.
55+
* There is no schemaField resource type in {@link PoliciesConfig}, so a dataset-scoped policy
56+
* never matches a field URN and only platform admins would pass the check. Authorizing on the
57+
* parent matches how the field page already reads its permissions, and it matches column edits,
58+
* where EDIT_DATASET_COL_* is a dataset privilege rather than a field-entity one.
59+
*
60+
* <p>The parent is usually a dataset, but GraphQL allows dashboard and chart parents too, so the
61+
* parent is read from the URN rather than assumed. A field URN that does not parse falls back to
62+
* itself, which preserves the previous behaviour instead of failing open.
63+
*
64+
* <p>This applies to the incident edit check only. View restrictions and EDIT_DATASET_COL_* are
65+
* unaffected.
66+
*/
67+
@Nonnull
68+
public static Urn getIncidentAuthorizationUrn(@Nonnull final Urn resourceUrn) {
69+
if (!Constants.SCHEMA_FIELD_ENTITY_NAME.equals(resourceUrn.getEntityType())) {
70+
return resourceUrn;
71+
}
72+
return SchemaFieldUtils.parseSchemaFieldUrn(resourceUrn)
73+
.map(Pair::getFirst)
74+
.orElse(resourceUrn);
75+
}
76+
4877
public static boolean isAuthorizedToEditIncidentForResource(
4978
final Urn resourceUrn, final QueryContext context) {
5079
final DisjunctivePrivilegeGroup orPrivilegeGroups =
@@ -54,8 +83,9 @@ public static boolean isAuthorizedToEditIncidentForResource(
5483
new ConjunctivePrivilegeGroup(
5584
ImmutableList.of(PoliciesConfig.EDIT_ENTITY_INCIDENTS_PRIVILEGE.getType()))));
5685

86+
final Urn authorizationUrn = getIncidentAuthorizationUrn(resourceUrn);
5787
return AuthorizationUtils.isAuthorized(
58-
context, resourceUrn.getEntityType(), resourceUrn.toString(), orPrivilegeGroups);
88+
context, authorizationUrn.getEntityType(), authorizationUrn.toString(), orPrivilegeGroups);
5989
}
6090

6191
@Nullable

‎datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/RaiseIncidentResolver.java‎

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,16 @@
11
package com.linkedin.datahub.graphql.resolvers.incident;
22

3-
import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.ALL_PRIVILEGES_GROUP;
43
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*;
54
import static com.linkedin.datahub.graphql.resolvers.incident.IncidentUtils.*;
65
import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.*;
76
import static com.linkedin.metadata.Constants.*;
87

9-
import com.datahub.authorization.ConjunctivePrivilegeGroup;
10-
import com.datahub.authorization.DisjunctivePrivilegeGroup;
11-
import com.google.common.collect.ImmutableList;
128
import com.linkedin.common.AuditStamp;
139
import com.linkedin.common.UrnArray;
1410
import com.linkedin.common.urn.Urn;
1511
import com.linkedin.data.template.SetMode;
1612
import com.linkedin.data.template.StringMap;
1713
import com.linkedin.datahub.graphql.QueryContext;
18-
import com.linkedin.datahub.graphql.authorization.AuthorizationUtils;
1914
import com.linkedin.datahub.graphql.concurrency.GraphQLConcurrencyUtils;
2015
import com.linkedin.datahub.graphql.exception.AuthorizationException;
2116
import com.linkedin.datahub.graphql.exception.DataHubGraphQLErrorCode;
@@ -28,7 +23,6 @@
2823
import com.linkedin.incident.IncidentSourceType;
2924
import com.linkedin.incident.IncidentType;
3025
import com.linkedin.metadata.aspect.validation.CreateIfNotExistsValidator;
31-
import com.linkedin.metadata.authorization.PoliciesConfig;
3226
import com.linkedin.metadata.key.IncidentKey;
3327
import com.linkedin.metadata.utils.GenericRecordUtils;
3428
import com.linkedin.mxe.MetadataChangeProposal;
@@ -75,7 +69,7 @@ public CompletableFuture<String> get(DataFetchingEnvironment environment) throws
7569
return GraphQLConcurrencyUtils.supplyAsync(
7670
() -> {
7771
for (Urn urn : resourceUrns) {
78-
if (!isAuthorizedToCreateIncidentForResource(urn, context)) {
72+
if (!IncidentUtils.isAuthorizedToEditIncidentForResource(urn, context)) {
7973
throw new AuthorizationException(
8074
"Unauthorized to perform this action. Please contact your DataHub administrator.");
8175
}
@@ -194,17 +188,4 @@ private IncidentInfo mapIncidentInfo(
194188
result.setStatus(IncidentUtils.mapIncidentStatus(input.getStatus(), actorStamp));
195189
return result;
196190
}
197-
198-
private boolean isAuthorizedToCreateIncidentForResource(
199-
final Urn resourceUrn, final QueryContext context) {
200-
final DisjunctivePrivilegeGroup orPrivilegeGroups =
201-
new DisjunctivePrivilegeGroup(
202-
ImmutableList.of(
203-
ALL_PRIVILEGES_GROUP,
204-
new ConjunctivePrivilegeGroup(
205-
ImmutableList.of(PoliciesConfig.EDIT_ENTITY_INCIDENTS_PRIVILEGE.getType()))));
206-
207-
return AuthorizationUtils.isAuthorized(
208-
context, resourceUrn.getEntityType(), resourceUrn.toString(), orPrivilegeGroups);
209-
}
210191
}

‎datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentStatusResolver.java‎

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
11
package com.linkedin.datahub.graphql.resolvers.incident;
22

3-
import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.ALL_PRIVILEGES_GROUP;
43
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*;
54
import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.*;
65
import static com.linkedin.metadata.Constants.*;
76

8-
import com.datahub.authorization.ConjunctivePrivilegeGroup;
9-
import com.datahub.authorization.DisjunctivePrivilegeGroup;
10-
import com.google.common.collect.ImmutableList;
117
import com.linkedin.common.AuditStamp;
128
import com.linkedin.common.urn.Urn;
139
import com.linkedin.common.urn.UrnUtils;
1410
import com.linkedin.datahub.graphql.QueryContext;
15-
import com.linkedin.datahub.graphql.authorization.AuthorizationUtils;
1611
import com.linkedin.datahub.graphql.concurrency.GraphQLConcurrencyUtils;
1712
import com.linkedin.datahub.graphql.exception.AuthorizationException;
1813
import com.linkedin.datahub.graphql.exception.DataHubGraphQLErrorCode;
@@ -23,7 +18,6 @@
2318
import com.linkedin.incident.IncidentStage;
2419
import com.linkedin.incident.IncidentState;
2520
import com.linkedin.incident.IncidentStatus;
26-
import com.linkedin.metadata.authorization.PoliciesConfig;
2721
import com.linkedin.metadata.entity.EntityService;
2822
import com.linkedin.metadata.entity.EntityUtils;
2923
import com.linkedin.mxe.MetadataChangeProposal;
@@ -63,7 +57,7 @@ public CompletableFuture<Boolean> get(final DataFetchingEnvironment environment)
6357
// Check whether the actor has permission to edit the incident
6458
// Currently only supporting a single entity. TODO: Support multiple incident entities.
6559
final Urn resourceUrn = info.getEntities().get(0);
66-
if (isAuthorizedToUpdateIncident(resourceUrn, context)) {
60+
if (IncidentUtils.isAuthorizedToEditIncidentForResource(resourceUrn, context)) {
6761
info.setStatus(
6862
new IncidentStatus()
6963
.setState(IncidentState.valueOf(input.getState().name()))
@@ -98,15 +92,4 @@ public CompletableFuture<Boolean> get(final DataFetchingEnvironment environment)
9892
this.getClass().getSimpleName(),
9993
"get");
10094
}
101-
102-
private boolean isAuthorizedToUpdateIncident(final Urn resourceUrn, final QueryContext context) {
103-
final DisjunctivePrivilegeGroup orPrivilegeGroups =
104-
new DisjunctivePrivilegeGroup(
105-
ImmutableList.of(
106-
ALL_PRIVILEGES_GROUP,
107-
new ConjunctivePrivilegeGroup(
108-
ImmutableList.of(PoliciesConfig.EDIT_ENTITY_INCIDENTS_PRIVILEGE.getType()))));
109-
return AuthorizationUtils.isAuthorized(
110-
context, resourceUrn.getEntityType(), resourceUrn.toString(), orPrivilegeGroups);
111-
}
11295
}

‎datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/TestUtils.java‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,46 @@ public static OperationContext withDefaultSearchEntityTypes(
153153
.build(operationContext.getSessionActorContext(), false);
154154
}
155155

156+
/**
157+
* Returns a context that allows a single privilege on a single resource URN and denies everything
158+
* else. Lets a test assert which URN a check was actually made against, rather than only whether
159+
* it passed.
160+
*/
161+
public static QueryContext getMockAllowContextForResource(
162+
@Nonnull final String actorUrn,
163+
@Nonnull final String privilege,
164+
@Nonnull final Urn allowedResourceUrn) {
165+
Authorizer mockAuthorizer = mock(Authorizer.class);
166+
when(mockAuthorizer.authorize(any(AuthorizationRequest.class)))
167+
.thenAnswer(
168+
args -> {
169+
AuthorizationRequest request = args.getArgument(0);
170+
boolean allowed =
171+
privilege.equals(request.getPrivilege())
172+
&& request
173+
.getResourceSpec()
174+
.map(spec -> allowedResourceUrn.toString().equals(spec.getEntity()))
175+
.orElse(false);
176+
return new AuthorizationResult(
177+
request,
178+
allowed ? AuthorizationResult.Type.ALLOW : AuthorizationResult.Type.DENY,
179+
"");
180+
});
181+
182+
Authentication authentication =
183+
new Authentication(new Actor(ActorType.USER, UrnUtils.getUrn(actorUrn).getId()), "creds");
184+
185+
QueryContext mockContext = mock(QueryContext.class);
186+
when(mockContext.getActorUrn()).thenReturn(actorUrn);
187+
when(mockContext.getAuthorizer()).thenReturn(mockAuthorizer);
188+
when(mockContext.getAuthentication()).thenReturn(authentication);
189+
OperationContext operationContext =
190+
withDefaultSearchEntityTypes(
191+
TestOperationContexts.userContextNoSearchAuthorization(mockAuthorizer, authentication));
192+
when(mockContext.getOperationContext()).thenReturn(operationContext);
193+
return mockContext;
194+
}
195+
156196
public static QueryContext getMockDenyContext() {
157197
return getMockDenyContext("urn:li:corpuser:test");
158198
}

‎datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/IncidentUtilsTest.java‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
package com.linkedin.datahub.graphql.resolvers.incident;
22

3+
import static com.linkedin.datahub.graphql.TestUtils.getMockAllowContextForResource;
34
import static org.testng.Assert.*;
45

56
import com.linkedin.common.AuditStamp;
67
import com.linkedin.common.urn.Urn;
78
import com.linkedin.common.urn.UrnUtils;
9+
import com.linkedin.datahub.graphql.QueryContext;
810
import com.linkedin.datahub.graphql.generated.IncidentPriority;
911
import com.linkedin.datahub.graphql.generated.IncidentStatusInput;
1012
import com.linkedin.incident.IncidentAssigneeArray;
1113
import com.linkedin.incident.IncidentStage;
1214
import com.linkedin.incident.IncidentState;
1315
import com.linkedin.incident.IncidentStatus;
16+
import com.linkedin.metadata.authorization.PoliciesConfig;
17+
import com.linkedin.metadata.utils.SchemaFieldUtils;
1418
import java.util.Arrays;
1519
import java.util.Collections;
1620
import java.util.List;
@@ -19,6 +23,15 @@
1923
public class IncidentUtilsTest {
2024

2125
private static final Urn TEST_USER_URN = UrnUtils.getUrn("urn:li:corpuser:test");
26+
private static final Urn TEST_DATASET_URN =
27+
UrnUtils.getUrn("urn:li:dataset:(urn:li:dataPlatform:hive,SampleTable,PROD)");
28+
private static final Urn TEST_OTHER_DATASET_URN =
29+
UrnUtils.getUrn("urn:li:dataset:(urn:li:dataPlatform:hive,OtherTable,PROD)");
30+
private static final Urn TEST_CHART_URN = UrnUtils.getUrn("urn:li:chart:(looker,baz)");
31+
private static final Urn TEST_SCHEMA_FIELD_URN =
32+
SchemaFieldUtils.generateSchemaFieldUrn(TEST_DATASET_URN, "user_id");
33+
private static final String EDIT_INCIDENTS =
34+
PoliciesConfig.EDIT_ENTITY_INCIDENTS_PRIVILEGE.getType();
2235

2336
@Test
2437
public void testMapIncidentPriorityWithNull() {
@@ -103,4 +116,57 @@ public void testMapIncidentStatusWithValidInput() {
103116
assertEquals(status.getStage(), IncidentStage.INVESTIGATION);
104117
assertEquals(status.getMessage(), "Issue resolved");
105118
}
119+
120+
@Test
121+
public void testGetIncidentAuthorizationUrnRemapsSchemaFieldToParent() {
122+
assertEquals(
123+
IncidentUtils.getIncidentAuthorizationUrn(TEST_SCHEMA_FIELD_URN), TEST_DATASET_URN);
124+
}
125+
126+
@Test
127+
public void testGetIncidentAuthorizationUrnLeavesOtherEntitiesAlone() {
128+
assertEquals(IncidentUtils.getIncidentAuthorizationUrn(TEST_DATASET_URN), TEST_DATASET_URN);
129+
assertEquals(IncidentUtils.getIncidentAuthorizationUrn(TEST_CHART_URN), TEST_CHART_URN);
130+
}
131+
132+
@Test
133+
public void testGetIncidentAuthorizationUrnFallsBackWhenFieldUrnDoesNotParse() {
134+
// A schemaField URN missing its field path cannot yield a parent, so the check stays on the
135+
// URN it was given rather than failing open.
136+
Urn malformed = UrnUtils.getUrn("urn:li:schemaField:notAFieldUrn");
137+
assertEquals(IncidentUtils.getIncidentAuthorizationUrn(malformed), malformed);
138+
}
139+
140+
@Test
141+
public void testEditIncidentOnSchemaFieldAllowedByParentDatasetPolicy() {
142+
QueryContext context =
143+
getMockAllowContextForResource(TEST_USER_URN.toString(), EDIT_INCIDENTS, TEST_DATASET_URN);
144+
assertTrue(IncidentUtils.isAuthorizedToEditIncidentForResource(TEST_SCHEMA_FIELD_URN, context));
145+
}
146+
147+
@Test
148+
public void testEditIncidentOnSchemaFieldDeniedWhenParentPolicyDoesNotMatch() {
149+
QueryContext context =
150+
getMockAllowContextForResource(
151+
TEST_USER_URN.toString(), EDIT_INCIDENTS, TEST_OTHER_DATASET_URN);
152+
assertFalse(
153+
IncidentUtils.isAuthorizedToEditIncidentForResource(TEST_SCHEMA_FIELD_URN, context));
154+
}
155+
156+
@Test
157+
public void testEditIncidentOnSchemaFieldUsesNonDatasetParent() {
158+
Urn chartFieldUrn = SchemaFieldUtils.generateSchemaFieldUrn(TEST_CHART_URN, "user_id");
159+
QueryContext context =
160+
getMockAllowContextForResource(TEST_USER_URN.toString(), EDIT_INCIDENTS, TEST_CHART_URN);
161+
assertTrue(IncidentUtils.isAuthorizedToEditIncidentForResource(chartFieldUrn, context));
162+
}
163+
164+
@Test
165+
public void testEditIncidentOnOtherEntitiesIsUnchanged() {
166+
QueryContext context =
167+
getMockAllowContextForResource(TEST_USER_URN.toString(), EDIT_INCIDENTS, TEST_DATASET_URN);
168+
assertTrue(IncidentUtils.isAuthorizedToEditIncidentForResource(TEST_DATASET_URN, context));
169+
// The remap is scoped to schemaField: a dataset policy still does not reach other entities.
170+
assertFalse(IncidentUtils.isAuthorizedToEditIncidentForResource(TEST_CHART_URN, context));
171+
}
106172
}

0 commit comments

Comments
 (0)