Skip to content

Commit

Permalink
feat(echo): Support for restricting who can perform a manual judgment
Browse files Browse the repository at this point in the history
This PR verifies the current user roles against `context.selectedStageRoles`.

The operation is _only_ allowed if there is at least one overlapping role between
`context.selectedStageRoles` and the current user roles.

`context.selectedStageRoles` maps to what is currently being specified in the UI.

This is a rewrite of what originally went in as spinnaker#3988.
  • Loading branch information
ajordens committed Dec 15, 2020
1 parent 3e2d691 commit c57316d
Show file tree
Hide file tree
Showing 6 changed files with 340 additions and 30 deletions.
1 change: 1 addition & 0 deletions orca-echo/orca-echo.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-autoconfigure")
implementation("javax.validation:validation-api")
implementation("com.netflix.spinnaker.fiat:fiat-core:$fiatVersion")
implementation("com.netflix.spinnaker.fiat:fiat-api:$fiatVersion")

testImplementation("com.squareup.retrofit:retrofit-mock")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import com.netflix.spinnaker.orca.api.pipeline.models.ExecutionStatus
import com.netflix.spinnaker.orca.api.pipeline.OverridableTimeoutRetryableTask
import com.netflix.spinnaker.orca.api.pipeline.models.StageExecution
import com.netflix.spinnaker.orca.api.pipeline.TaskResult
import com.netflix.spinnaker.orca.echo.util.ManualJudgmentAuthorization

import javax.annotation.Nonnull
import java.util.concurrent.TimeUnit
Expand All @@ -42,7 +43,7 @@ class ManualJudgmentStage implements StageDefinitionBuilder, AuthenticatedStage
@Override
void taskGraph(@Nonnull StageExecution stage, @Nonnull TaskNode.Builder builder) {
builder
.withTask("waitForJudgment", WaitForManualJudgmentTask.class)
.withTask("waitForJudgment", WaitForManualJudgmentTask.class)
}

@Override
Expand Down Expand Up @@ -72,8 +73,15 @@ class ManualJudgmentStage implements StageDefinitionBuilder, AuthenticatedStage
final long backoffPeriod = 15000
final long timeout = TimeUnit.DAYS.toMillis(3)

@Autowired(required = false)
EchoService echoService
private final EchoService echoService
private final ManualJudgmentAuthorization manualJudgmentAuthorization

@Autowired
WaitForManualJudgmentTask(EchoService echoService,
ManualJudgmentAuthorization manualJudgmentAuthorization) {
this.echoService = echoService
this.manualJudgmentAuthorization = manualJudgmentAuthorization
}

@Override
TaskResult execute(StageExecution stage) {
Expand All @@ -96,6 +104,17 @@ class ManualJudgmentStage implements StageDefinitionBuilder, AuthenticatedStage
break
}

if (stageData.state != StageData.State.UNKNOWN && !stageData.selectedStageRoles.isEmpty()) {
def application = stage.execution.application
def currentUser = stage.lastModified?.user

if (!manualJudgmentAuthorization.isAuthorized(application, stageData.selectedStageRoles, currentUser)) {
notificationState = "manualJudgment"
executionStatus = ExecutionStatus.RUNNING
stage.context.put("judgmentStatus", "")
}
}

Map outputs = processNotifications(stage, stageData, notificationState)

return TaskResult.builder(executionStatus).context(outputs).build()
Expand Down Expand Up @@ -128,6 +147,7 @@ class ManualJudgmentStage implements StageDefinitionBuilder, AuthenticatedStage
static class StageData {
String judgmentStatus = ""
List<Notification> notifications = []
List<String> selectedStageRoles = []
boolean propagateAuthenticationContext

State getState() {
Expand Down Expand Up @@ -194,27 +214,27 @@ class ManualJudgmentStage implements StageDefinitionBuilder, AuthenticatedStage

void notify(EchoService echoService, StageExecution stage, String notificationState) {
echoService.create(new EchoService.Notification(
notificationType: EchoService.Notification.Type.valueOf(type.toUpperCase()),
to: address ? [address] : (publisherName ? [publisherName] : null),
cc: cc ? [cc] : null,
templateGroup: notificationState,
severity: EchoService.Notification.Severity.HIGH,
source: new EchoService.Notification.Source(
executionType: stage.execution.type.toString(),
executionId: stage.execution.id,
application: stage.execution.application
),
additionalContext: [
stageName: stage.name,
stageId: stage.refId,
restrictExecutionDuringTimeWindow: stage.context.restrictExecutionDuringTimeWindow,
execution: stage.execution,
instructions: stage.context.instructions ?: "",
message: message?.get(notificationState)?.text,
judgmentInputs: stage.context.judgmentInputs,
judgmentInput: stage.context.judgmentInput,
judgedBy: stage.context.lastModifiedBy
]
notificationType: EchoService.Notification.Type.valueOf(type.toUpperCase()),
to: address ? [address] : (publisherName ? [publisherName] : null),
cc: cc ? [cc] : null,
templateGroup: notificationState,
severity: EchoService.Notification.Severity.HIGH,
source: new EchoService.Notification.Source(
executionType: stage.execution.type.toString(),
executionId: stage.execution.id,
application: stage.execution.application
),
additionalContext: [
stageName : stage.name,
stageId : stage.refId,
restrictExecutionDuringTimeWindow: stage.context.restrictExecutionDuringTimeWindow,
execution : stage.execution,
instructions : stage.context.instructions ?: "",
message : message?.get(notificationState)?.text,
judgmentInputs : stage.context.judgmentInputs,
judgmentInput : stage.context.judgmentInput,
judgedBy : stage.context.lastModifiedBy
]
))
lastNotifiedByNotificationState[notificationState] = new Date()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* Copyright 2020 OpsMx, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.netflix.spinnaker.orca.echo.util;

import static java.lang.String.format;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Strings;
import com.google.common.collect.Sets;
import com.netflix.spinnaker.fiat.model.UserPermission;
import com.netflix.spinnaker.fiat.model.resources.Role;
import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator;
import com.netflix.spinnaker.fiat.shared.FiatStatus;
import com.netflix.spinnaker.kork.exceptions.SpinnakerException;
import com.netflix.spinnaker.orca.front50.Front50Service;
import com.netflix.spinnaker.orca.front50.model.Application;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import retrofit.RetrofitError;

@Component
public class ManualJudgmentAuthorization {
private final Logger log = LoggerFactory.getLogger(getClass());

private final Front50Service front50Service;
private final FiatPermissionEvaluator fiatPermissionEvaluator;

private final FiatStatus fiatStatus;
private final ObjectMapper objectMapper;

@Autowired
public ManualJudgmentAuthorization(
Optional<Front50Service> front50Service,
Optional<FiatPermissionEvaluator> fiatPermissionEvaluator,
FiatStatus fiatStatus,
ObjectMapper objectMapper) {
this.front50Service = front50Service.orElse(null);
this.fiatPermissionEvaluator = fiatPermissionEvaluator.orElse(null);

this.fiatStatus = fiatStatus;
this.objectMapper = objectMapper;
}

/**
* A manual judgment will be considered "authorized" if the current user has at least one of the
* required judgment roles _and_ read/write/execute permissions on the application being judged.
*
* @param application Application being judged
* @param requiredJudgmentRoles Required judgment roles
* @param currentUser User that has attempted this judgment
* @return whether or not {@param currentUser} has authorization to judge
*/
public boolean isAuthorized(
String application, Collection<String> requiredJudgmentRoles, String currentUser) {
if (!fiatStatus.isEnabled() || requiredJudgmentRoles.isEmpty()) {
return true;
}

if (Strings.isNullOrEmpty(currentUser)) {
return false;
}

UserPermission.View permission = fiatPermissionEvaluator.getPermission(currentUser);
if (permission == null) { // Should never happen?
log.warn("Attempted to get user permission for '{}' but none were found.", currentUser);
return false;
}

return isAuthorized(
requiredJudgmentRoles,
permission.getRoles().stream().map(Role.View::getName).collect(Collectors.toList()),
getApplicationPermissions(application));
}

private boolean isAuthorized(
Collection<String> requiredJudgmentRoles,
Collection<String> currentUserRoles,
Map<String, Object> applicationPermissions) {
if (requiredJudgmentRoles == null || requiredJudgmentRoles.isEmpty()) {
return true;
}

return Sets.intersection(new HashSet<>(requiredJudgmentRoles), new HashSet<>(currentUserRoles))
.size()
> 0;

// boolean isAuthorizedGroup = false;
//
// for (String role : currentUserRoles) {
// if (requiredJudgmentRoles.contains(role)) {
// for (Map.Entry<String, Object> entry :
// applicationPermissions.entrySet()) { // get the application permission roles.
// if (Authorization.CREATE.name().equals(entry.getKey())
// || Authorization.EXECUTE.name().equals(entry.getKey())
// || Authorization.WRITE.name().equals(entry.getKey())) {
// // If the application permission roles has 'CREATE, EXECUTE, WRITE', then user is
// // authorized.
// if (entry.getValue() != null && ((List<String>) entry.getValue()).contains(role))
// {
// return true;
// }
// } else if (Authorization.READ.name().equals(entry.getKey())) {
// // If the application permission roles has 'READ', then user is not authorized.
// if (entry.getValue() != null && ((List<String>) entry.getValue()).contains(role))
// {
// isAuthorizedGroup = false;
// }
// }
// }
// }
// }
//
// return isAuthorizedGroup;
}

private Map<String, Object> getApplicationPermissions(String applicationName) {
return getApplication(applicationName)
.map(
application -> {
if (application.getPermission().getPermissions() != null) {
return objectMapper.convertValue(
application.getPermission().getPermissions(),
new TypeReference<Map<String, Object>>() {});
}

return new HashMap<String, Object>();
})
.orElse(new HashMap<>());
}

private Optional<Application> getApplication(String applicationName) {
if (front50Service == null) {
return Optional.empty();
}

try {
return Optional.of(front50Service.get(applicationName));
} catch (RetrofitError e) {
if (e.getResponse().getStatus() == HttpStatus.NOT_FOUND.value()) {
return Optional.empty();
}
throw new SpinnakerException(
format("Failed to retrieve application '%s'", applicationName), e);
} catch (RuntimeException re) {
return Optional.empty();
}
}
}
Loading

0 comments on commit c57316d

Please sign in to comment.