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. (#4023)

The operation is only allowed if there is at least one overlapping role (or the
user is an admin).

This is a rewrite of what originally went in as #3988.
  • Loading branch information
ajordens committed Dec 15, 2020
1 parent 3e2d691 commit f6965c3
Show file tree
Hide file tree
Showing 6 changed files with 248 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 (or the current user is an admin).
*
* @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 permission.isAdmin()
|| 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))
.isEmpty();
}
}
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,38 @@ class ManualJudgmentStageSpec extends Specification {
[judgmentStatus: "unknown"] || ExecutionStatus.RUNNING
}

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

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

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:
isAdmin | context || expectedStatus
false | [judgmentStatus: "continue", selectedStageRoles: ['foo']] || ExecutionStatus.SUCCEEDED
false | [judgmentStatus: "Continue", selectedStageRoles: ['foo']] || ExecutionStatus.SUCCEEDED
false | [judgmentStatus: "stop", selectedStageRoles: ['foo']] || ExecutionStatus.TERMINAL
false | [judgmentStatus: "STOP", selectedStageRoles: ['foo']] || ExecutionStatus.TERMINAL
false | [judgmentStatus: "Continue", selectedStageRoles: ['baz']] || ExecutionStatus.RUNNING
false | [judgmentStatus: "Stop", selectedStageRoles: ['baz']] || ExecutionStatus.RUNNING
true | [judgmentStatus: "Stop", selectedStageRoles: ['baz']] || ExecutionStatus.TERMINAL
true | [judgmentStatus: "Continue", selectedStageRoles: ['baz']] || ExecutionStatus.SUCCEEDED
}

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 +118,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 +199,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 f6965c3

Please sign in to comment.