Skip to content

Commit

Permalink
This PR verifies the current user roles against context.selectedStage…
Browse files Browse the repository at this point in the history
…Roles.

The operation is only allowed if there is at least one overlapping role.

This is a rewrite of what originally went in as spinnaker#3988.
  • Loading branch information
ajordens committed Dec 15, 2020
1 parent 3e2d691 commit b7020ae
Show file tree
Hide file tree
Showing 6 changed files with 246 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(Optional<EchoService> echoService,
ManualJudgmentAuthorization manualJudgmentAuthorization) {
this.echoService = echoService.orElse(null)
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.getRequiredJudgmentRoles().isEmpty()) {
// only check authorization _if_ a judgment has been made and required judgment roles have been specified
def currentUser = stage.lastModified?.user

if (!manualJudgmentAuthorization.isAuthorized(stageData.getRequiredJudgmentRoles(), 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,8 +147,15 @@ class ManualJudgmentStage implements StageDefinitionBuilder, AuthenticatedStage
static class StageData {
String judgmentStatus = ""
List<Notification> notifications = []
Set<String> selectedStageRoles = []
Set<String> requiredJudgmentRoles = []
boolean propagateAuthenticationContext

Set<String> getRequiredJudgmentRoles() {
// UI is currently configuring 'selectedStageRoles' so this will fallback to that if not otherwise specified
return requiredJudgmentRoles ?: selectedStageRoles ?: []
}

State getState() {
switch (judgmentStatus?.toLowerCase()) {
case "continue":
Expand Down Expand Up @@ -194,27 +220,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,93 @@
/*
* 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 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 java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
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.stereotype.Component;

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

private final FiatPermissionEvaluator fiatPermissionEvaluator;

private final FiatStatus fiatStatus;

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

this.fiatStatus = fiatStatus;
}

/**
* A manual judgment will be considered "authorized" if the current user has at least one of the
* required judgment roles.
*
* @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(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()));
}

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

if (currentUserRoles == null) {
currentUserRoles = new ArrayList<>();
}

return Sets.intersection(new HashSet<>(requiredJudgmentRoles), new HashSet<>(currentUserRoles))
.size()
> 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,14 @@

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

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.ManualJudgmentAuthorization
import com.netflix.spinnaker.orca.pipeline.model.PipelineExecutionImpl
import com.netflix.spinnaker.orca.pipeline.model.StageExecutionImpl
import spock.lang.Specification
Expand All @@ -27,17 +32,29 @@ 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)

FiatPermissionEvaluator fiatPermissionEvaluator = Mock(FiatPermissionEvaluator)

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

ManualJudgmentAuthorization manualJudgmentAuthorization = new ManualJudgmentAuthorization(
Optional.of(fiatPermissionEvaluator),
fiatStatus
)

@Unroll
void "should return execution status based on judgmentStatus"() {
given:
def task = new WaitForManualJudgmentTask()
def task = new WaitForManualJudgmentTask(Optional.of(echoService), manualJudgmentAuthorization)

when:
def result = task.execute(new StageExecutionImpl(PipelineExecutionImpl.newPipeline("orca"), "", context))

then:
result.status == expectedStatus
result.context.isEmpty()

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

@Unroll
void "should return execution status based on authorizedGroups"() {
given:
1 * fiatPermissionEvaluator.getPermission('[email protected]') >> {
new UserPermission().addResources([new Role('foo')]).view
}

def task = new WaitForManualJudgmentTask(Optional.of(echoService), manualJudgmentAuthorization)

when:
def stage = new StageExecutionImpl(PipelineExecutionImpl.newPipeline("orca"), "", context)
stage.lastModified = new StageExecution.LastModifiedDetails(user: "[email protected]", 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), manualJudgmentAuthorization)

when:
def result = task.execute(new StageExecutionImpl(PipelineExecutionImpl.newPipeline("orca"), "", [notifications: [
Expand All @@ -72,7 +116,7 @@ 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), manualJudgmentAuthorization)

when:
def result = task.execute(new StageExecutionImpl(PipelineExecutionImpl.newPipeline("orca"), "", [
Expand Down Expand Up @@ -153,7 +197,7 @@ 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), manualJudgmentAuthorization)

def slackNotification = new Notification(type: "slack")
slackNotification.setOther("customMessage", "hello slack")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* 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 com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator
import com.netflix.spinnaker.fiat.shared.FiatStatus
import spock.lang.Specification
import spock.lang.Subject
import spock.lang.Unroll

class ManualJudgmentAuthorizationSpec extends Specification {
def fiatPermissionEvaluator = Mock(FiatPermissionEvaluator)
def fiatStatus = Mock(FiatStatus)

@Subject
def manualJudgmentAuthorization = new ManualJudgmentAuthorization(
Optional.of(fiatPermissionEvaluator),
fiatStatus
)

@Unroll
void 'should determine authorization based on intersection of userRoles and stageRoles/permissions'() {
when:
def result = manualJudgmentAuthorization.isAuthorized(requiredJudgmentRoles, currentUserRoles)

then:
result == isAuthorized

where:
requiredJudgmentRoles | currentUserRoles || isAuthorized
['foo', 'blaz'] | ['foo', 'baz'] || true
[] | ['foo', 'baz'] || true
[] | [] || true
['foo'] | ['foo'] || true
['foo'] | [] || false
['foo'] | null || false
null | null || true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import com.netflix.spinnaker.kork.web.interceptors.MetricsInterceptor
import com.netflix.spinnaker.orca.jackson.OrcaObjectMapper
import groovy.transform.CompileStatic
import org.springframework.beans.factory.annotation.Autowire
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.ComponentScan
Expand Down

0 comments on commit b7020ae

Please sign in to comment.