-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat (resolver): Add new endpoint for editing secrets (#9665)
- Loading branch information
Showing
8 changed files
with
304 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
82 changes: 82 additions & 0 deletions
82
.../main/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/UpdateSecretResolver.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
package com.linkedin.datahub.graphql.resolvers.ingest.secret; | ||
|
||
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; | ||
import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.buildMetadataChangeProposalWithUrn; | ||
import static com.linkedin.metadata.Constants.SECRET_VALUE_ASPECT_NAME; | ||
|
||
import com.linkedin.common.urn.Urn; | ||
import com.linkedin.datahub.graphql.QueryContext; | ||
import com.linkedin.datahub.graphql.exception.AuthorizationException; | ||
import com.linkedin.datahub.graphql.generated.UpdateSecretInput; | ||
import com.linkedin.datahub.graphql.resolvers.ingest.IngestionAuthUtils; | ||
import com.linkedin.datahub.graphql.types.ingest.secret.mapper.DataHubSecretValueMapper; | ||
import com.linkedin.entity.EntityResponse; | ||
import com.linkedin.entity.client.EntityClient; | ||
import com.linkedin.metadata.secret.SecretService; | ||
import com.linkedin.mxe.MetadataChangeProposal; | ||
import com.linkedin.secret.DataHubSecretValue; | ||
import graphql.schema.DataFetcher; | ||
import graphql.schema.DataFetchingEnvironment; | ||
import java.util.Set; | ||
import java.util.concurrent.CompletableFuture; | ||
import lombok.RequiredArgsConstructor; | ||
import lombok.extern.slf4j.Slf4j; | ||
|
||
/** | ||
* Creates an encrypted DataHub secret. Uses AES symmetric encryption / decryption. Requires the | ||
* MANAGE_SECRETS privilege. | ||
*/ | ||
@Slf4j | ||
@RequiredArgsConstructor | ||
public class UpdateSecretResolver implements DataFetcher<CompletableFuture<String>> { | ||
private final EntityClient entityClient; | ||
private final SecretService secretService; | ||
|
||
@Override | ||
public CompletableFuture<String> get(final DataFetchingEnvironment environment) throws Exception { | ||
final QueryContext context = environment.getContext(); | ||
final UpdateSecretInput input = | ||
bindArgument(environment.getArgument("input"), UpdateSecretInput.class); | ||
final Urn secretUrn = Urn.createFromString(input.getUrn()); | ||
return CompletableFuture.supplyAsync( | ||
() -> { | ||
if (IngestionAuthUtils.canManageSecrets(context)) { | ||
|
||
try { | ||
EntityResponse response = | ||
entityClient.getV2( | ||
secretUrn.getEntityType(), | ||
secretUrn, | ||
Set.of(SECRET_VALUE_ASPECT_NAME), | ||
context.getAuthentication()); | ||
if (!entityClient.exists(secretUrn, context.getAuthentication()) | ||
|| response == null) { | ||
throw new IllegalArgumentException( | ||
String.format("Secret for urn %s doesn't exists!", secretUrn)); | ||
} | ||
|
||
DataHubSecretValue updatedVal = | ||
DataHubSecretValueMapper.map( | ||
response, | ||
input.getName(), | ||
secretService.encrypt(input.getValue()), | ||
input.getDescription(), | ||
null); | ||
|
||
final MetadataChangeProposal proposal = | ||
buildMetadataChangeProposalWithUrn( | ||
secretUrn, SECRET_VALUE_ASPECT_NAME, updatedVal); | ||
return entityClient.ingestProposal(proposal, context.getAuthentication(), false); | ||
} catch (Exception e) { | ||
throw new RuntimeException( | ||
String.format( | ||
"Failed to update a secret with urn %s and name %s", | ||
secretUrn, input.getName()), | ||
e); | ||
} | ||
} | ||
throw new AuthorizationException( | ||
"Unauthorized to perform this action. Please contact your DataHub administrator."); | ||
}); | ||
} | ||
} |
55 changes: 55 additions & 0 deletions
55
...ava/com/linkedin/datahub/graphql/types/ingest/secret/mapper/DataHubSecretValueMapper.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
package com.linkedin.datahub.graphql.types.ingest.secret.mapper; | ||
|
||
import static com.linkedin.metadata.Constants.SECRET_VALUE_ASPECT_NAME; | ||
|
||
import com.linkedin.common.AuditStamp; | ||
import com.linkedin.data.template.RecordTemplate; | ||
import com.linkedin.data.template.SetMode; | ||
import com.linkedin.entity.EntityResponse; | ||
import com.linkedin.secret.DataHubSecretValue; | ||
import java.util.Objects; | ||
import javax.annotation.Nonnull; | ||
|
||
/** | ||
* Maps Pegasus {@link RecordTemplate} objects to objects conforming to the GQL schema. | ||
* | ||
* <p>To be replaced by auto-generated mappers implementations | ||
*/ | ||
public class DataHubSecretValueMapper { | ||
|
||
public static final DataHubSecretValueMapper INSTANCE = new DataHubSecretValueMapper(); | ||
|
||
public static DataHubSecretValue map( | ||
EntityResponse fromSecret, | ||
@Nonnull final String name, | ||
@Nonnull final String value, | ||
String description, | ||
AuditStamp auditStamp) { | ||
return INSTANCE.apply(fromSecret, name, value, description, auditStamp); | ||
} | ||
|
||
public DataHubSecretValue apply( | ||
EntityResponse existingSecret, | ||
@Nonnull final String name, | ||
@Nonnull final String value, | ||
String description, | ||
AuditStamp auditStamp) { | ||
final DataHubSecretValue result; | ||
if (Objects.nonNull(existingSecret)) { | ||
result = | ||
new DataHubSecretValue( | ||
existingSecret.getAspects().get(SECRET_VALUE_ASPECT_NAME).getValue().data()); | ||
} else { | ||
result = new DataHubSecretValue(); | ||
} | ||
|
||
result.setName(name); | ||
result.setValue(value); | ||
result.setDescription(description, SetMode.IGNORE_NULL); | ||
if (Objects.nonNull(auditStamp)) { | ||
result.setCreated(auditStamp); | ||
} | ||
|
||
return result; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
98 changes: 98 additions & 0 deletions
98
...t/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/UpdateSecretResolverTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
package com.linkedin.datahub.graphql.resolvers.ingest.secret; | ||
|
||
import static com.linkedin.datahub.graphql.resolvers.ingest.IngestTestUtils.getMockAllowContext; | ||
import static com.linkedin.datahub.graphql.resolvers.ingest.IngestTestUtils.getMockDenyContext; | ||
import static com.linkedin.metadata.Constants.SECRET_VALUE_ASPECT_NAME; | ||
import static org.mockito.ArgumentMatchers.any; | ||
import static org.mockito.ArgumentMatchers.anyBoolean; | ||
import static org.mockito.Mockito.when; | ||
|
||
import com.datahub.authentication.Authentication; | ||
import com.linkedin.common.AuditStamp; | ||
import com.linkedin.common.urn.Urn; | ||
import com.linkedin.common.urn.UrnUtils; | ||
import com.linkedin.datahub.graphql.QueryContext; | ||
import com.linkedin.datahub.graphql.exception.AuthorizationException; | ||
import com.linkedin.datahub.graphql.generated.UpdateSecretInput; | ||
import com.linkedin.entity.Aspect; | ||
import com.linkedin.entity.EntityResponse; | ||
import com.linkedin.entity.EnvelopedAspect; | ||
import com.linkedin.entity.EnvelopedAspectMap; | ||
import com.linkedin.entity.client.EntityClient; | ||
import com.linkedin.metadata.secret.SecretService; | ||
import com.linkedin.secret.DataHubSecretValue; | ||
import graphql.schema.DataFetchingEnvironment; | ||
import java.util.concurrent.CompletionException; | ||
import org.mockito.Mockito; | ||
import org.testng.annotations.BeforeMethod; | ||
import org.testng.annotations.Test; | ||
|
||
public class UpdateSecretResolverTest { | ||
|
||
private static final Urn TEST_URN = UrnUtils.getUrn("urn:li:secret:secret-id"); | ||
|
||
private static final UpdateSecretInput TEST_INPUT = | ||
new UpdateSecretInput(TEST_URN.toString(), "MY_SECRET", "mysecretvalue", "dummy"); | ||
|
||
private DataFetchingEnvironment mockEnv; | ||
private EntityClient mockClient; | ||
private SecretService mockSecretService; | ||
private UpdateSecretResolver resolver; | ||
|
||
@BeforeMethod | ||
public void before() { | ||
mockClient = Mockito.mock(EntityClient.class); | ||
mockSecretService = Mockito.mock(SecretService.class); | ||
|
||
resolver = new UpdateSecretResolver(mockClient, mockSecretService); | ||
} | ||
|
||
private DataHubSecretValue createSecretAspect() { | ||
DataHubSecretValue secretAspect = new DataHubSecretValue(); | ||
secretAspect.setValue("encryptedvalue.updated"); | ||
secretAspect.setName(TEST_INPUT.getName() + ".updated"); | ||
secretAspect.setDescription(TEST_INPUT.getDescription() + ".updated"); | ||
secretAspect.setCreated( | ||
new AuditStamp().setActor(UrnUtils.getUrn("urn:li:corpuser:test")).setTime(0L)); | ||
return secretAspect; | ||
} | ||
|
||
@Test | ||
public void testGetSuccess() throws Exception { | ||
// with valid context | ||
QueryContext mockContext = getMockAllowContext(); | ||
mockEnv = Mockito.mock(DataFetchingEnvironment.class); | ||
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); | ||
Mockito.when(mockEnv.getContext()).thenReturn(mockContext); | ||
|
||
Mockito.when(mockClient.exists(any(), any())).thenReturn(true); | ||
Mockito.when(mockSecretService.encrypt(any())).thenReturn("encrypted_value"); | ||
final EntityResponse entityResponse = new EntityResponse(); | ||
final EnvelopedAspectMap aspectMap = new EnvelopedAspectMap(); | ||
aspectMap.put( | ||
SECRET_VALUE_ASPECT_NAME, | ||
new EnvelopedAspect().setValue(new Aspect(createSecretAspect().data()))); | ||
entityResponse.setAspects(aspectMap); | ||
|
||
when(mockClient.getV2(any(), any(), any(), any())).thenReturn(entityResponse); | ||
|
||
// Invoke the resolver | ||
resolver.get(mockEnv).join(); | ||
Mockito.verify(mockClient, Mockito.times(1)).ingestProposal(any(), any(), anyBoolean()); | ||
} | ||
|
||
@Test( | ||
description = "validate if nothing provided throws Exception", | ||
expectedExceptions = {AuthorizationException.class, CompletionException.class}) | ||
public void testGetUnauthorized() throws Exception { | ||
// Execute resolver | ||
QueryContext mockContext = getMockDenyContext(); | ||
mockEnv = Mockito.mock(DataFetchingEnvironment.class); | ||
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); | ||
Mockito.when(mockEnv.getContext()).thenReturn(mockContext); | ||
|
||
resolver.get(mockEnv).join(); | ||
Mockito.verify(mockClient, Mockito.times(0)) | ||
.ingestProposal(any(), any(Authentication.class), anyBoolean()); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters