diff --git a/fiat-api/fiat-api.gradle b/fiat-api/fiat-api.gradle index c10714475..292a8ace8 100644 --- a/fiat-api/fiat-api.gradle +++ b/fiat-api/fiat-api.gradle @@ -39,3 +39,7 @@ dependencies { testImplementation "org.slf4j:slf4j-api" } + +test { + useJUnitPlatform() +} diff --git a/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/AuthenticatedRequestAuthenticationConverter.java b/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/AuthenticatedRequestAuthenticationConverter.java new file mode 100644 index 000000000..56f837705 --- /dev/null +++ b/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/AuthenticatedRequestAuthenticationConverter.java @@ -0,0 +1,48 @@ +/* + * Copyright 2022 Apple, 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.fiat.shared; + +import com.netflix.spinnaker.security.AuthenticatedRequest; +import java.util.List; +import javax.servlet.http.HttpServletRequest; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; + +/** + * Provides an Authentication for an HTTP request using the current {@link + * AuthenticatedRequest#getSpinnakerUser()}. + * + * @see AuthenticatedRequest + */ +public class AuthenticatedRequestAuthenticationConverter implements AuthenticationConverter { + @Override + public Authentication convert(HttpServletRequest request) { + return AuthenticatedRequest.getSpinnakerUser() + .map( + user -> + (Authentication) new PreAuthenticatedAuthenticationToken(user, "N/A", List.of())) + .orElseGet( + () -> + new AnonymousAuthenticationToken( + "anonymous", + "anonymous", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))); + } +} diff --git a/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatAuthenticationConfig.java b/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatAuthenticationConfig.java index abf90f36a..d9d1b652c 100644 --- a/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatAuthenticationConfig.java +++ b/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatAuthenticationConfig.java @@ -32,6 +32,7 @@ import okhttp3.OkHttpClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; @@ -43,6 +44,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; +import org.springframework.security.web.authentication.AuthenticationConverter; import retrofit.Endpoints; import retrofit.RestAdapter; import retrofit.converter.JacksonConverter; @@ -85,9 +87,33 @@ public FiatService fiatService( .create(FiatService.class); } + /** + * When enabled, this authenticates the {@code X-SPINNAKER-USER} HTTP header using permissions + * obtained from {@link FiatPermissionEvaluator#getPermission(String)}. This feature is part of a + * larger effort to adopt standard Spring Security APIs rather than using Fiat directly where + * possible. + */ + @ConditionalOnProperty("services.fiat.granted-authorities.enabled") @Bean - FiatWebSecurityConfigurerAdapter fiatSecurityConfig(FiatStatus fiatStatus) { - return new FiatWebSecurityConfigurerAdapter(fiatStatus); + AuthenticationConverter fiatAuthenticationFilter(FiatPermissionEvaluator permissionEvaluator) { + return new FiatAuthenticationConverter(permissionEvaluator); + } + + /** + * Provides the previous behavior of using PreAuthenticatedAuthenticationToken with no granted + * authorities to indicate an authenticated user or an AnonymousAuthenticationToken with an + * "ANONYMOUS" role for anonymous authenticated users. + */ + @ConditionalOnMissingBean + @Bean + AuthenticationConverter defaultAuthenticationConverter() { + return new AuthenticatedRequestAuthenticationConverter(); + } + + @Bean + FiatWebSecurityConfigurerAdapter fiatSecurityConfig( + FiatStatus fiatStatus, AuthenticationConverter authenticationConverter) { + return new FiatWebSecurityConfigurerAdapter(fiatStatus, authenticationConverter); } @Bean @@ -97,12 +123,15 @@ FiatAccessDeniedExceptionHandler fiatAccessDeniedExceptionHandler( return new FiatAccessDeniedExceptionHandler(exceptionMessageDecorator); } - private class FiatWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter { + private static class FiatWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter { private final FiatStatus fiatStatus; + private final AuthenticationConverter authenticationConverter; - private FiatWebSecurityConfigurerAdapter(FiatStatus fiatStatus) { + private FiatWebSecurityConfigurerAdapter( + FiatStatus fiatStatus, AuthenticationConverter authenticationConverter) { super(true); this.fiatStatus = fiatStatus; + this.authenticationConverter = authenticationConverter; } @Override @@ -114,7 +143,8 @@ protected void configure(HttpSecurity http) throws Exception { .anonymous() .and() .addFilterBefore( - new FiatAuthenticationFilter(fiatStatus), AnonymousAuthenticationFilter.class); + new FiatAuthenticationFilter(fiatStatus, authenticationConverter), + AnonymousAuthenticationFilter.class); } } } diff --git a/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatAuthenticationConverter.java b/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatAuthenticationConverter.java new file mode 100644 index 000000000..92d30494e --- /dev/null +++ b/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatAuthenticationConverter.java @@ -0,0 +1,52 @@ +/* + * Copyright 2022 Apple, 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.fiat.shared; + +import com.netflix.spinnaker.fiat.model.SpinnakerAuthorities; +import com.netflix.spinnaker.fiat.model.UserPermission; +import com.netflix.spinnaker.kork.common.Header; +import java.util.List; +import javax.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; + +/** + * Converts an {@code X-SPINNAKER-USER} HTTP header into an Authentication object containing a list + * of roles and other {@linkplain SpinnakerAuthorities granted authorities} in its granted + * authorities. + */ +@RequiredArgsConstructor +public class FiatAuthenticationConverter implements AuthenticationConverter { + private final FiatPermissionEvaluator permissionEvaluator; + + @Override + public Authentication convert(HttpServletRequest request) { + String user = request.getHeader(Header.USER.getHeader()); + if (user != null) { + UserPermission.View permission = permissionEvaluator.getPermission(user); + if (permission != null) { + return new PreAuthenticatedAuthenticationToken( + user, "N/A", permission.toGrantedAuthorities()); + } + } + return new AnonymousAuthenticationToken( + "anonymous", "anonymous", List.of(SpinnakerAuthorities.ANONYMOUS_AUTHORITY)); + } +} diff --git a/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatAuthenticationFilter.java b/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatAuthenticationFilter.java index dd583efc0..895214ba5 100644 --- a/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatAuthenticationFilter.java +++ b/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatAuthenticationFilter.java @@ -16,58 +16,40 @@ package com.netflix.spinnaker.fiat.shared; -import com.netflix.spinnaker.security.AuthenticatedRequest; import java.io.IOException; -import java.util.ArrayList; -import javax.servlet.*; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpFilter; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; -import lombok.val; -import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +import org.springframework.security.web.authentication.AuthenticationConverter; @Slf4j -public class FiatAuthenticationFilter implements Filter { +public class FiatAuthenticationFilter extends HttpFilter { private final FiatStatus fiatStatus; + private final AuthenticationConverter authenticationConverter; - public FiatAuthenticationFilter(FiatStatus fiatStatus) { + public FiatAuthenticationFilter( + FiatStatus fiatStatus, AuthenticationConverter authenticationConverter) { this.fiatStatus = fiatStatus; + this.authenticationConverter = authenticationConverter; } @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException { - if (!fiatStatus.isEnabled()) { - chain.doFilter(request, response); - return; + if (fiatStatus.isEnabled()) { + SecurityContext ctx = SecurityContextHolder.createEmptyContext(); + Authentication auth = authenticationConverter.convert(req); + ctx.setAuthentication(auth); + SecurityContextHolder.setContext(ctx); + log.debug("Set SecurityContext to user: {}", auth.getPrincipal().toString()); } - - Authentication auth = - AuthenticatedRequest.getSpinnakerUser() - .map( - username -> - (Authentication) - new PreAuthenticatedAuthenticationToken(username, null, new ArrayList<>())) - .orElseGet( - () -> - new AnonymousAuthenticationToken( - "anonymous", - "anonymous", - AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))); - - val ctx = SecurityContextHolder.createEmptyContext(); - ctx.setAuthentication(auth); - SecurityContextHolder.setContext(ctx); - log.debug("Set SecurityContext to user: {}", auth.getPrincipal().toString()); - chain.doFilter(request, response); + chain.doFilter(req, res); } - - @Override - public void init(FilterConfig filterConfig) throws ServletException {} - - @Override - public void destroy() {} } diff --git a/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatClientConfigurationProperties.java b/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatClientConfigurationProperties.java index 8e356c45b..46b016ef5 100644 --- a/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatClientConfigurationProperties.java +++ b/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatClientConfigurationProperties.java @@ -43,6 +43,9 @@ public class FiatClientConfigurationProperties { @NestedConfigurationProperty private RetryConfiguration retry = new RetryConfiguration(); + @NestedConfigurationProperty + private GrantedAuthorities grantedAuthorities = new GrantedAuthorities(); + public RetryConfiguration getRetry() { retry.setDynamicConfigService(dynamicConfigService); return retry; @@ -93,4 +96,9 @@ public double getRetryMultiplier() { Double.class, "fiat.retry.retryMultiplier", retryMultiplier); } } + + @Data + public static class GrantedAuthorities { + private boolean enabled; + } } diff --git a/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatPermissionEvaluator.java b/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatPermissionEvaluator.java index 94bfcb5e6..098ebec8f 100644 --- a/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatPermissionEvaluator.java +++ b/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatPermissionEvaluator.java @@ -21,12 +21,14 @@ import com.netflix.spectator.api.Id; import com.netflix.spectator.api.Registry; import com.netflix.spinnaker.fiat.model.Authorization; +import com.netflix.spinnaker.fiat.model.SpinnakerAuthorities; import com.netflix.spinnaker.fiat.model.UserPermission; import com.netflix.spinnaker.fiat.model.resources.Account; import com.netflix.spinnaker.fiat.model.resources.Authorizable; import com.netflix.spinnaker.fiat.model.resources.ResourceType; import com.netflix.spinnaker.kork.exceptions.IntegrationException; import com.netflix.spinnaker.kork.telemetry.caffeine.CaffeineStatsCounter; +import com.netflix.spinnaker.security.AccessControlled; import com.netflix.spinnaker.security.AuthenticatedRequest; import java.io.Serializable; import java.util.Arrays; @@ -148,6 +150,25 @@ private static RetryHandler buildRetryHandler( @Override public boolean hasPermission( Authentication authentication, Object resource, Object authorization) { + if (!fiatStatus.isGrantedAuthoritiesEnabled()) { + return false; + } + if (!fiatStatus.isEnabled()) { + return true; + } + if (authentication == null || resource == null) { + log.warn( + "Permission denied because at least one of the required arguments was null. authentication={}, resource={}", + authentication, + resource); + return false; + } + if (authentication.getAuthorities().contains(SpinnakerAuthorities.ADMIN_AUTHORITY)) { + return true; + } + if (resource instanceof AccessControlled) { + return ((AccessControlled) resource).isAuthorized(authentication, authorization); + } return false; } @@ -223,7 +244,7 @@ public boolean hasPermission( // Service accounts don't have read/write authorizations. if (!r.equals(ResourceType.SERVICE_ACCOUNT)) { - a = Authorization.valueOf(authorization.toString()); + a = Authorization.parse(authorization); } if (a == Authorization.CREATE) { diff --git a/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatStatus.java b/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatStatus.java index e15757ee3..f5314c301 100644 --- a/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatStatus.java +++ b/fiat-api/src/main/java/com/netflix/spinnaker/fiat/shared/FiatStatus.java @@ -35,6 +35,7 @@ public class FiatStatus { private final AtomicBoolean enabled; private final AtomicBoolean legacyFallbackEnabled; + private final AtomicBoolean grantedAuthoritiesEnabled; @Autowired public FiatStatus( @@ -47,6 +48,8 @@ public FiatStatus( this.enabled = new AtomicBoolean(fiatClientConfigurationProperties.isEnabled()); this.legacyFallbackEnabled = new AtomicBoolean(fiatClientConfigurationProperties.isLegacyFallback()); + this.grantedAuthoritiesEnabled = + new AtomicBoolean(fiatClientConfigurationProperties.getGrantedAuthorities().isEnabled()); PolledMeter.using(registry) .withName("fiat.enabled") @@ -54,6 +57,9 @@ public FiatStatus( PolledMeter.using(registry) .withName("fiat.legacyFallback.enabled") .monitorValue(legacyFallbackEnabled, value -> legacyFallbackEnabled.get() ? 1 : 0); + PolledMeter.using(registry) + .withName("fiat.granted-authorities.enabled") + .monitorValue(grantedAuthoritiesEnabled, value -> grantedAuthoritiesEnabled.get() ? 1 : 0); } public boolean isEnabled() { @@ -64,6 +70,10 @@ public boolean isLegacyFallbackEnabled() { return legacyFallbackEnabled.get(); } + public boolean isGrantedAuthoritiesEnabled() { + return grantedAuthoritiesEnabled.get(); + } + @Scheduled(fixedDelay = 30000L) void refreshStatus() { try { @@ -76,6 +86,10 @@ void refreshStatus() { legacyFallbackEnabled.set( dynamicConfigService.isEnabled( "fiat.legacyFallback", fiatClientConfigurationProperties.isLegacyFallback())); + grantedAuthoritiesEnabled.set( + dynamicConfigService.isEnabled( + "fiat.granted-authorities", + fiatClientConfigurationProperties.getGrantedAuthorities().isEnabled())); } catch (Exception e) { log.warn("Unable to refresh fiat status, reason: {}", e.getMessage()); } diff --git a/fiat-api/src/test/groovy/com/netflix/spinnaker/fiat/shared/AccessControlledResource.java b/fiat-api/src/test/groovy/com/netflix/spinnaker/fiat/shared/AccessControlledResource.java new file mode 100644 index 000000000..ebd91260e --- /dev/null +++ b/fiat-api/src/test/groovy/com/netflix/spinnaker/fiat/shared/AccessControlledResource.java @@ -0,0 +1,37 @@ +/* + * Copyright 2022 Apple, 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.fiat.shared; + +import com.netflix.spinnaker.fiat.model.Authorization; +import com.netflix.spinnaker.fiat.model.resources.Permissions; +import com.netflix.spinnaker.security.AccessControlled; +import java.util.Objects; +import org.springframework.security.core.Authentication; + +public class AccessControlledResource implements AccessControlled { + private final Permissions permissions; + + public AccessControlledResource(Permissions permissions) { + this.permissions = Objects.requireNonNull(permissions); + } + + @Override + public boolean isAuthorized(Authentication authentication, Object authorization) { + Authorization a = Authorization.parse(authorization); + return permissions.getAuthorizations(authentication.getAuthorities()).contains(a); + } +} diff --git a/fiat-api/src/test/groovy/com/netflix/spinnaker/fiat/shared/FiatPermissionEvaluatorSpec.groovy b/fiat-api/src/test/groovy/com/netflix/spinnaker/fiat/shared/FiatPermissionEvaluatorSpec.groovy index ac1544ab0..cf823a19a 100644 --- a/fiat-api/src/test/groovy/com/netflix/spinnaker/fiat/shared/FiatPermissionEvaluatorSpec.groovy +++ b/fiat-api/src/test/groovy/com/netflix/spinnaker/fiat/shared/FiatPermissionEvaluatorSpec.groovy @@ -17,6 +17,7 @@ package com.netflix.spinnaker.fiat.shared import com.netflix.spinnaker.fiat.model.Authorization +import com.netflix.spinnaker.fiat.model.SpinnakerAuthorities import com.netflix.spinnaker.fiat.model.UserPermission import com.netflix.spinnaker.fiat.model.resources.Application import com.netflix.spinnaker.fiat.model.resources.Authorizable @@ -49,7 +50,8 @@ class FiatPermissionEvaluatorSpec extends FiatSharedSpecification { ) @Shared - def authentication = new PreAuthenticatedAuthenticationToken("testUser", null, []) + def authentication = new PreAuthenticatedAuthenticationToken("testUser", null, + [SpinnakerAuthorities.forRoleName('test group')]) def cleanup() { MDC.clear() @@ -290,6 +292,7 @@ class FiatPermissionEvaluatorSpec extends FiatSharedSpecification { configurationProperties.enabled = true configurationProperties.cache.maxEntries = 0 configurationProperties.cache.expiresAfterWriteSeconds = 0 + configurationProperties.grantedAuthorities.enabled = true return configurationProperties } @@ -298,4 +301,26 @@ class FiatPermissionEvaluatorSpec extends FiatSharedSpecification { String name Set authorizations } + + def "should evaluate permissions for AccessControlled objects"() { + given: + def resource = new AccessControlledResource(new Permissions.Builder().add(Authorization.READ, 'test group').build()) + + when: + def hasPermission = evaluator.hasPermission(authentication, resource, authorization) + + then: + hasPermission == expectedHasPermission + + where: + authorization | expectedHasPermission + 'read' | true + "read" | true + 'READ' | true + "READ" | true + Authorization.READ | true + 'write' | false + 'WRITE' | false + 'EXECUTE' | false + } } diff --git a/fiat-api/src/test/groovy/com/netflix/spinnaker/fiat/shared/FiatSharedSpecification.groovy b/fiat-api/src/test/groovy/com/netflix/spinnaker/fiat/shared/FiatSharedSpecification.groovy index 68bbad021..3891eb924 100644 --- a/fiat-api/src/test/groovy/com/netflix/spinnaker/fiat/shared/FiatSharedSpecification.groovy +++ b/fiat-api/src/test/groovy/com/netflix/spinnaker/fiat/shared/FiatSharedSpecification.groovy @@ -25,6 +25,7 @@ abstract class FiatSharedSpecification extends Specification { Registry registry = new NoopRegistry() FiatStatus fiatStatus = Mock(FiatStatus) { _ * isEnabled() >> { return true } + _ * isGrantedAuthoritiesEnabled() >> { return true } } private static FiatClientConfigurationProperties buildConfigurationProperties() { diff --git a/fiat-core/fiat-core.gradle b/fiat-core/fiat-core.gradle index ec531495e..4559abafb 100644 --- a/fiat-core/fiat-core.gradle +++ b/fiat-core/fiat-core.gradle @@ -1,5 +1,7 @@ dependencies { + api "org.springframework.security:spring-security-core" + implementation "com.fasterxml.jackson.core:jackson-annotations" implementation "com.google.code.findbugs:jsr305" implementation "org.slf4j:slf4j-api" diff --git a/fiat-core/src/main/java/com/netflix/spinnaker/fiat/model/Authorization.java b/fiat-core/src/main/java/com/netflix/spinnaker/fiat/model/Authorization.java index a3eeb5bc4..19a812180 100644 --- a/fiat-core/src/main/java/com/netflix/spinnaker/fiat/model/Authorization.java +++ b/fiat-core/src/main/java/com/netflix/spinnaker/fiat/model/Authorization.java @@ -18,7 +18,9 @@ import java.util.Collections; import java.util.EnumSet; +import java.util.Locale; import java.util.Set; +import javax.annotation.Nullable; public enum Authorization { READ, @@ -28,4 +30,12 @@ public enum Authorization { public static final Set ALL = Collections.unmodifiableSet(EnumSet.allOf(Authorization.class)); + + @Nullable + public static Authorization parse(Object o) { + if (o instanceof Authorization) { + return (Authorization) o; + } + return o != null ? valueOf(o.toString().toUpperCase(Locale.ROOT)) : null; + } } diff --git a/fiat-core/src/main/java/com/netflix/spinnaker/fiat/model/SpinnakerAuthorities.java b/fiat-core/src/main/java/com/netflix/spinnaker/fiat/model/SpinnakerAuthorities.java new file mode 100644 index 000000000..a934cd97f --- /dev/null +++ b/fiat-core/src/main/java/com/netflix/spinnaker/fiat/model/SpinnakerAuthorities.java @@ -0,0 +1,44 @@ +/* + * Copyright 2022 Apple, 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.fiat.model; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +/** + * Constants and utilities for working with Spring Security GrantedAuthority objects specific to + * Spinnaker and Fiat. Spinnaker-specific roles such as admin and account manager are represented + * here as granted authorities. + */ +public class SpinnakerAuthorities { + public static final String ADMIN = "SPINNAKER_ADMIN"; + /** Granted authority for Spinnaker administrators. */ + public static final GrantedAuthority ADMIN_AUTHORITY = new SimpleGrantedAuthority(ADMIN); + + public static final String ACCOUNT_MANAGER = "SPINNAKER_ACCOUNT_MANAGER"; + /** Granted authority for Spinnaker account managers. */ + public static final GrantedAuthority ACCOUNT_MANAGER_AUTHORITY = + new SimpleGrantedAuthority(ACCOUNT_MANAGER); + + /** Granted authority for anonymous users. */ + public static final GrantedAuthority ANONYMOUS_AUTHORITY = forRoleName("ANONYMOUS"); + + /** Creates a granted authority corresponding to the provided name of a role. */ + public static GrantedAuthority forRoleName(String role) { + return new SimpleGrantedAuthority(String.format("ROLE_%s", role)); + } +} diff --git a/fiat-core/src/main/java/com/netflix/spinnaker/fiat/model/UserPermission.java b/fiat-core/src/main/java/com/netflix/spinnaker/fiat/model/UserPermission.java index 0a3e075fe..9c78c2ea0 100644 --- a/fiat-core/src/main/java/com/netflix/spinnaker/fiat/model/UserPermission.java +++ b/fiat-core/src/main/java/com/netflix/spinnaker/fiat/model/UserPermission.java @@ -25,6 +25,7 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.val; +import org.springframework.security.core.GrantedAuthority; @Data public class UserPermission { @@ -153,5 +154,24 @@ public View(UserPermission permission) { this.admin = permission.isAdmin(); this.accountManager = permission.isAccountManager(); } + + /** + * Returns this user permission view as a set of granted authorities. This authority set + * contains the user's roles along with authorities indicating if they're Spinnaker admins or + * account managers. + */ + public Set toGrantedAuthorities() { + Set authorities = new LinkedHashSet<>(); + if (isAdmin()) { + authorities.add(SpinnakerAuthorities.ADMIN_AUTHORITY); + } + if (isAccountManager()) { + authorities.add(SpinnakerAuthorities.ACCOUNT_MANAGER_AUTHORITY); + } + for (Role.View role : getRoles()) { + authorities.add(SpinnakerAuthorities.forRoleName(role.getName())); + } + return authorities; + } } } diff --git a/fiat-core/src/main/java/com/netflix/spinnaker/fiat/model/resources/Permissions.java b/fiat-core/src/main/java/com/netflix/spinnaker/fiat/model/resources/Permissions.java index 7a22266f9..7ccc439af 100644 --- a/fiat-core/src/main/java/com/netflix/spinnaker/fiat/model/resources/Permissions.java +++ b/fiat-core/src/main/java/com/netflix/spinnaker/fiat/model/resources/Permissions.java @@ -25,6 +25,7 @@ import java.util.*; import java.util.stream.Collectors; import lombok.val; +import org.springframework.security.core.GrantedAuthority; /** * Representation of authorization configuration for a resource. This object is immutable, which @@ -91,6 +92,17 @@ public Set getAuthorizations(List userRoles) { return getAuthorizationsFromRoles(new LinkedHashSet<>(userRoles)); } + public Set getAuthorizations( + Collection userAuthorities) { + Set userRoles = + userAuthorities.stream() + .map(GrantedAuthority::getAuthority) + .filter(authority -> authority.startsWith("ROLE_")) + .map(authority -> authority.substring("ROLE_".length())) + .collect(Collectors.toSet()); + return getAuthorizationsFromRoles(userRoles); + } + private Set getAuthorizationsFromRoles(Set userRoles) { if (!isRestricted()) { return Authorization.ALL; diff --git a/fiat-core/src/test/groovy/com/netflix/spinnaker/fiat/model/UserPermissionTest.java b/fiat-core/src/test/groovy/com/netflix/spinnaker/fiat/model/UserPermissionTest.java new file mode 100644 index 000000000..e7a82fcd3 --- /dev/null +++ b/fiat-core/src/test/groovy/com/netflix/spinnaker/fiat/model/UserPermissionTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2022 Apple, 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.fiat.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.netflix.spinnaker.fiat.model.resources.Role; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; + +class UserPermissionTest { + @Test + void rolesConvertToGrantedAuthorities() { + Role first = new Role("first"); + Role second = new Role("second"); + Role third = new Role("third"); + UserPermission userPermission = + new UserPermission().setId("jesse").setRoles(Set.of(first, second, third)); + Set authorities = userPermission.getView().toGrantedAuthorities(); + Set result = AuthorityUtils.authorityListToSet(authorities); + assertThat(result).containsExactlyInAnyOrder("ROLE_first", "ROLE_second", "ROLE_third"); + } + + @Test + void adminRoleConvertsToGrantedAuthority() { + UserPermission userPermission = new UserPermission().setId("sherry").setAdmin(true); + Set authorities = userPermission.getView().toGrantedAuthorities(); + assertThat(authorities).containsExactly(SpinnakerAuthorities.ADMIN_AUTHORITY); + } + + @Test + void accountManagerConvertsToGrantedAuthority() { + UserPermission userPermission = new UserPermission().setId("ralph").setAccountManager(true); + Set authorities = userPermission.getView().toGrantedAuthorities(); + assertThat(authorities).containsExactly(SpinnakerAuthorities.ACCOUNT_MANAGER_AUTHORITY); + } +}