Skip to content

Commit

Permalink
feat(pipeline executions/orca) : Added ability to add roles to manual…
Browse files Browse the repository at this point in the history
… judgment stage. (#3988)

* feat(pipeline executions/orca) : Added ability to add roles to manual judgment stage.

This is part of: spinnaker/spinnaker#4792.

Enhanced OperationsController.groovy to

Get the application roles, stage roles and the user roles.
Check each of the user roles whether they are contained in the stage and application roles.
Once the user role is contained in the manual judgment stage.
We check for whether the stage role has 'READ', 'WRITE', 'EXECUTE', 'CREATE' role in the application permissions,
if the role has application permission as 'READ', then the user will not be allowed to proceed further to subsequent(downstream) stages.
if the role has application permission as 'WRITE, EXECUTE,CREATE', then the user will be allowed to proceed further to subsequent stages.
If yes/no, set a isAuthorized flag to true/false in each of the stage. By default, all the stages except Manual Judgment are true.

Enhanced ManualJudgmentStage.groovy to

Check for the flag in ManualJudgmentStage.groovy whether to execute to the next stage or not.
If yes/no, continue with the next stages/continues running the same stage.

Enhanced ManualJudgmentStageSpec.groovy to

Modified the testcases as per the requirement.

Added ManualJudgmentAuthzGroupsUtil.groovy

Added a utlity method to check authorized groups of the manual judgment stage.

Added ManualJudgmentAuthzGroupsUtilSpec.groovy

Added test cases check authorized groups method of ManualJudgmentAuthzGroupsUtil.groovy.

* feat(pipeline executions/orca) : Added ability to add roles to manual judgment stage.
This is part of: spinnaker/spinnaker#4792.
Enhanced OperationsController.groovy to
Get the application roles, stage roles and the user roles.
Check each of the user roles whether they are contained in the stage and application roles.
Once the user role is contained in the manual judgment stage.
We check for whether the stage role has 'READ', 'WRITE', 'EXECUTE', 'CREATE' role in the application permissions,
if the role has application permission as 'READ', then the user will not be allowed to proceed further to subsequent(downstream) stages.
if the role has application permission as 'WRITE, EXECUTE,CREATE', then the user will be allowed to proceed further to subsequent stages.
If yes/no, set a isAuthorized flag to true/false in each of the stage. By default, all the stages except Manual Judgment are true.
Enhanced ManualJudgmentStage.groovy to
Check for the flag in ManualJudgmentStage.groovy whether to execute toThis is part of: spinnaker/spinnaker#4792.
Enhanced OperationsController.groovy to
 tEnhanced OperationsController.groovy to
GeeSGet the application roles, stage roless Enhanced ManualJudgmentStage.groovy to
Check for the flag in ManualJudgmentStage.groovy whether to execute toThis is part of: spinnaker/spinnaker#4792.
Enhanced OperationsController.groovy to
 tEnhanced OperationsController.groovy to
GeeSGet the application roles, stage roless

Co-authored-by: Adam Jordens <adam@jordens.org>
  • Loading branch information
sanopsmx and ajordens committed Dec 8, 2020
1 parent b0af060 commit b8c5a7d
Show file tree
Hide file tree
Showing 7 changed files with 306 additions and 27 deletions.
2 changes: 1 addition & 1 deletion orca-echo/orca-echo.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +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 @@ -19,23 +19,31 @@ package com.netflix.spinnaker.orca.echo.pipeline
import com.fasterxml.jackson.annotation.JsonAnyGetter
import com.fasterxml.jackson.annotation.JsonAnySetter
import com.fasterxml.jackson.annotation.JsonIgnore
import com.netflix.spinnaker.orca.api.pipeline.models.ExecutionStatus
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import com.google.common.annotations.VisibleForTesting
import com.google.common.base.Strings
import com.netflix.spinnaker.fiat.model.UserPermission
import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator
import com.netflix.spinnaker.fiat.shared.FiatStatus
import com.netflix.spinnaker.orca.AuthenticatedStage
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 javax.annotation.Nonnull
import java.util.concurrent.TimeUnit
import com.google.common.annotations.VisibleForTesting
import com.netflix.spinnaker.orca.*
import com.netflix.spinnaker.orca.echo.EchoService
import com.netflix.spinnaker.orca.api.pipeline.graph.StageDefinitionBuilder
import com.netflix.spinnaker.orca.api.pipeline.graph.TaskNode
import com.netflix.spinnaker.orca.api.pipeline.models.ExecutionStatus
import com.netflix.spinnaker.orca.api.pipeline.models.StageExecution
import com.netflix.spinnaker.orca.echo.EchoService
import com.netflix.spinnaker.orca.echo.util.ManualJudgmentAuthzGroupsUtil
import com.netflix.spinnaker.security.AuthenticatedRequest
import com.netflix.spinnaker.security.User
import groovy.util.logging.Slf4j
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Component

import javax.annotation.Nonnull
import java.util.concurrent.TimeUnit

@Component
class ManualJudgmentStage implements StageDefinitionBuilder, AuthenticatedStage {

Expand Down Expand Up @@ -72,21 +80,52 @@ 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 FiatPermissionEvaluator fiatPermissionEvaluator

private FiatStatus fiatStatus

private ManualJudgmentAuthzGroupsUtil manualJudgmentAuthzGroupsUtil

private ObjectMapper objectMapper

@Autowired
WaitForManualJudgmentTask(Optional<EchoService> echoService, Optional<FiatPermissionEvaluator> fpe,
Optional<FiatStatus> fiatStatus, Optional<ObjectMapper> objectMapper,
Optional<ManualJudgmentAuthzGroupsUtil> manualJudgmentAuthzGroupsUtil) {
this.echoService = echoService.orElse(null)
this.fiatPermissionEvaluator = fpe.orElse(null)
this.fiatStatus = fiatStatus.orElse(null)
this.objectMapper = objectMapper.orElse(null)
this.manualJudgmentAuthzGroupsUtil = manualJudgmentAuthzGroupsUtil.orElse(null)
}

@Override
TaskResult execute(StageExecution stage) {
StageData stageData = stage.mapTo(StageData)
def username = AuthenticatedRequest.getSpinnakerUser().orElse(stage.lastModified ? stage.lastModified.user : "")
boolean fiatEnabled = fiatStatus ? fiatStatus.isEnabled() : false
boolean isAuthorized = false
def appPermissions
def stageRoles
if (fiatEnabled) {
stageRoles = stage.context.selectedStageRoles
if (stageRoles) {
appPermissions = getApplicationPermissions(stage)
}
}
String notificationState
ExecutionStatus executionStatus

switch (stageData.state) {
case StageData.State.CONTINUE:
isAuthorized = !fiatEnabled || checkManualJudgmentAuthorizedGroups(stageRoles, appPermissions, username)
notificationState = "manualJudgmentContinue"
executionStatus = ExecutionStatus.SUCCEEDED
break
case StageData.State.STOP:
isAuthorized = !fiatEnabled || checkManualJudgmentAuthorizedGroups(stageRoles, appPermissions, username)
notificationState = "manualJudgmentStop"
executionStatus = ExecutionStatus.TERMINAL
break
Expand All @@ -95,12 +134,47 @@ class ManualJudgmentStage implements StageDefinitionBuilder, AuthenticatedStage
executionStatus = ExecutionStatus.RUNNING
break
}

if (!isAuthorized) {
notificationState = "manualJudgment"
executionStatus = ExecutionStatus.RUNNING
stage.context.put("judgmentStatus", "")
}
Map outputs = processNotifications(stage, stageData, notificationState)

return TaskResult.builder(executionStatus).context(outputs).build()
}

private Map<String, Object> getApplicationPermissions(StageExecution stage) {

def applicationName = stage.execution.application
def permissions
if (applicationName) {
manualJudgmentAuthzGroupsUtil.getApplication(applicationName).ifPresent({ application ->
if (application.getPermission().permissions && application.getPermission().permissions.permissions) {
permissions = objectMapper.convertValue(application.getPermission().permissions.permissions,
new TypeReference<Map<String, Object>>() {})
}
});
}
return permissions
}

boolean checkManualJudgmentAuthorizedGroups(List<String> stageRoles, Map<String, Object> permissions, String username) {

if (!Strings.isNullOrEmpty(username)) {
UserPermission.View permission = fiatPermissionEvaluator.getPermission(username);
if (permission == null) { // Should never happen?
log.warn("Attempted to get user permission for '$username' but none were found.")
return false;
}
// User has to have all the pipeline roles.
def userRoles = permission.getRoles().collect { it.getName().trim() }
return ManualJudgmentAuthzGroupsUtil.checkAuthorizedGroups(userRoles, stageRoles, permissions)
} else {
return false
}
}

Map processNotifications(StageExecution stage, StageData stageData, String notificationState) {
if (echoService) {
// sendNotifications will be true if using the new scheme for configuration notifications.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* 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.netflix.spinnaker.fiat.model.Authorization;
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.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import retrofit.RetrofitError;

public class ManualJudgmentAuthzGroupsUtil {

Front50Service front50Service;

@Autowired
public ManualJudgmentAuthzGroupsUtil(Optional<Front50Service> front50Service) {
this.front50Service = front50Service.orElse(null);
}

/**
* This method checks if the logged in user has role in the manual judgment stage authorized
* groups. We fetch the user roles and check if that role is authorized in the manual judgment
* stage role. if the user role exists , then we check with the application permission roles. If
* the application permission role has 'READ' then we return false(not authorized) If the
* application permission role has 'CREATE, EXECUTE, WRITE' then we return true(authorized)
*
* @param userRoles
* @param stageRoles
* @param permissions
* @return
*/
public static boolean checkAuthorizedGroups(
List<String> userRoles, List<String> stageRoles, Map<String, Object> permissions) {

boolean isAuthorizedGroup = false;
if (stageRoles == null || stageRoles.isEmpty()) {
return true;
}
for (String role : userRoles) { // Fetches the userRoles of the logged in user
if (stageRoles.contains(
role)) { // Checks if the user role is authorized in the manual judgment stage.
for (Map.Entry<String, Object> entry :
permissions.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;
}

public Optional<Application> getApplication(String applicationName) {
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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,17 @@

package com.netflix.spinnaker.orca.echo.pipeline

import com.fasterxml.jackson.databind.ObjectMapper
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.orca.api.pipeline.models.ExecutionStatus
import com.netflix.spinnaker.orca.api.pipeline.models.StageExecution
import com.netflix.spinnaker.orca.echo.EchoService
import com.netflix.spinnaker.orca.echo.util.ManualJudgmentAuthzGroupsUtil
import com.netflix.spinnaker.orca.front50.Front50Service
import com.netflix.spinnaker.orca.front50.model.Application
import com.netflix.spinnaker.orca.pipeline.model.PipelineExecutionImpl
import com.netflix.spinnaker.orca.pipeline.model.StageExecutionImpl
import spock.lang.Specification
Expand All @@ -27,17 +35,41 @@ import static com.netflix.spinnaker.orca.echo.pipeline.ManualJudgmentStage.Notif
import static com.netflix.spinnaker.orca.echo.pipeline.ManualJudgmentStage.WaitForManualJudgmentTask

class ManualJudgmentStageSpec extends Specification {

EchoService echoService = Mock(EchoService)

Front50Service front50Service = Mock(Front50Service)

FiatPermissionEvaluator fpe = Mock(FiatPermissionEvaluator)

FiatStatus fiatStatus = Mock() {
_ * isEnabled() >> true
}

ManualJudgmentAuthzGroupsUtil manualJudgmentAuthzGroupsUtil = new ManualJudgmentAuthzGroupsUtil(Optional.of(front50Service))

ObjectMapper objectMapper = new ObjectMapper()

def config = [
application: [
"name" : "orca",
"owner" : "owner",
"permissions" : [WRITE: ["foo"], READ: ["foo","baz"], EXECUTE: ["foo"]]
],
user : "testUser"
]

@Unroll
void "should return execution status based on judgmentStatus"() {
given:
def task = new WaitForManualJudgmentTask()

def task = new WaitForManualJudgmentTask(Optional.of(echoService), Optional.of(fpe), Optional.of(fiatStatus),
Optional.of(objectMapper), Optional.of(manualJudgmentAuthzGroupsUtil))
when:
def result = task.execute(new StageExecutionImpl(PipelineExecutionImpl.newPipeline("orca"), "", context))

then:
1 * fiatStatus.isEnabled() >> { return false }
result.status == expectedStatus
result.context.isEmpty()

where:
context || expectedStatus
Expand All @@ -49,9 +81,39 @@ class ManualJudgmentStageSpec extends Specification {
[judgmentStatus: "unknown"] || ExecutionStatus.RUNNING
}

@Unroll
void "should return execution status based on authorizedGroups"() {
given:
1 * fpe.getPermission('abc@somedomain.io') >> {
new UserPermission().addResources([new Role('foo'), new Role('baz')]).view
}
1 * front50Service.get("orca") >> new Application(config.application)

def task = new WaitForManualJudgmentTask(Optional.of(echoService), Optional.of(fpe), Optional.of(fiatStatus),
Optional.of(objectMapper), Optional.of(manualJudgmentAuthzGroupsUtil))

when:
def stage = new StageExecutionImpl(PipelineExecutionImpl.newPipeline("orca"), "", context)
stage.lastModified = new StageExecution.LastModifiedDetails(user: "abc@somedomain.io", allowedAccounts: ["group1"])
def result = task.execute(stage)

then:
result.status == expectedStatus

where:
context || expectedStatus
[judgmentStatus: "continue", selectedStageRoles: ['foo']] || ExecutionStatus.SUCCEEDED
[judgmentStatus: "Continue", selectedStageRoles: ['foo']] || ExecutionStatus.SUCCEEDED
[judgmentStatus: "stop", selectedStageRoles: ['foo']] || ExecutionStatus.TERMINAL
[judgmentStatus: "STOP", selectedStageRoles: ['foo']] || ExecutionStatus.TERMINAL
[judgmentStatus: "Continue", selectedStageRoles: ['baz']] || ExecutionStatus.RUNNING
[judgmentStatus: "Stop", selectedStageRoles: ['baz']] || ExecutionStatus.RUNNING
}

void "should only send notifications for supported types"() {
given:
def task = new WaitForManualJudgmentTask(echoService: Mock(EchoService))
def task = new WaitForManualJudgmentTask(Optional.of(echoService), Optional.of(fpe), Optional.of(fiatStatus),
Optional.of(objectMapper), Optional.of(manualJudgmentAuthzGroupsUtil))

when:
def result = task.execute(new StageExecutionImpl(PipelineExecutionImpl.newPipeline("orca"), "", [notifications: [
Expand All @@ -72,7 +134,8 @@ class ManualJudgmentStageSpec extends Specification {
@Unroll
void "if deprecated notification configuration is in use, only send notifications for awaiting judgment state"() {
given:
def task = new WaitForManualJudgmentTask(echoService: Mock(EchoService))
def task = new WaitForManualJudgmentTask(Optional.of(echoService), Optional.of(fpe), Optional.of(fiatStatus),
Optional.of(objectMapper), Optional.of(manualJudgmentAuthzGroupsUtil))

when:
def result = task.execute(new StageExecutionImpl(PipelineExecutionImpl.newPipeline("orca"), "", [
Expand All @@ -84,6 +147,7 @@ class ManualJudgmentStageSpec extends Specification {
]))

then:
1 * fiatStatus.isEnabled() >> { return false }
result.status == executionStatus
if (sent) result.context.notifications?.getAt(0)?.lastNotifiedByNotificationState?.containsKey(notificationState)

Expand Down Expand Up @@ -153,7 +217,8 @@ class ManualJudgmentStageSpec extends Specification {
@Unroll
void "should retain unknown fields in the notification context"() {
given:
def task = new WaitForManualJudgmentTask(echoService: Mock(EchoService))
def task = new WaitForManualJudgmentTask(Optional.of(echoService), Optional.of(fpe), Optional.of(fiatStatus),
Optional.of(objectMapper), Optional.of(manualJudgmentAuthzGroupsUtil))

def slackNotification = new Notification(type: "slack")
slackNotification.setOther("customMessage", "hello slack")
Expand Down
Loading

0 comments on commit b8c5a7d

Please sign in to comment.