diff --git a/.gitignore b/.gitignore index f49b4910db..0c47ff204d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ swagger.json swagger/swagger.json bin/ plugins/** + +.DS_Store +.vscode diff --git a/gate-oauth2/src/main/groovy/com/netflix/spinnaker/gate/security/oauth2/provider/GithubProviderTokenServices.groovy b/gate-oauth2/src/main/groovy/com/netflix/spinnaker/gate/security/oauth2/provider/GithubProviderTokenServices.groovy index 0aa44e252b..44ae42a4f2 100644 --- a/gate-oauth2/src/main/groovy/com/netflix/spinnaker/gate/security/oauth2/provider/GithubProviderTokenServices.groovy +++ b/gate-oauth2/src/main/groovy/com/netflix/spinnaker/gate/security/oauth2/provider/GithubProviderTokenServices.groovy @@ -16,6 +16,7 @@ package com.netflix.spinnaker.gate.security.oauth2.provider +import com.netflix.spinnaker.kork.annotations.VisibleForTesting import groovy.util.logging.Slf4j import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression @@ -27,6 +28,8 @@ import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken import org.springframework.security.oauth2.common.OAuth2AccessToken import org.springframework.security.oauth2.client.resource.BaseOAuth2ProtectedResourceDetails import org.springframework.stereotype.Component +import org.springframework.http.ResponseEntity +import org.springframework.http.HttpHeaders @Slf4j @Component @@ -39,7 +42,9 @@ class GithubProviderTokenServices implements SpinnakerProviderTokenServices { GithubRequirements requirements private String tokenType = DefaultOAuth2AccessToken.BEARER_TYPE - private OAuth2RestOperations restTemplate + + @VisibleForTesting + OAuth2RestOperations restTemplate @Component @ConfigurationProperties("security.oauth2.provider-requirements") @@ -71,8 +76,16 @@ class GithubProviderTokenServices implements SpinnakerProviderTokenServices { token.setTokenType(this.tokenType) restTemplate.getOAuth2ClientContext().setAccessToken(token) } - List> organizations = restTemplate.getForEntity(organizationsUrl, List.class).getBody() - return githubOrganizationMember(organization, organizations) + ResponseEntity>> response = restTemplate.getForEntity(organizationsUrl, List.class); + HttpHeaders headers = response.getHeaders(); + boolean isMember = githubOrganizationMember(organization, response.getBody()) + while (!isMember && hasNextPage(headers)) { + log.debug('Checking next page of user organizations') + response = restTemplate.getForEntity(nextPageUrl(nextLink(headers)), List.class) + isMember = githubOrganizationMember(organization, response.getBody()) + headers = response.getHeaders(); + } + return isMember } catch (Exception e) { log.warn("Could not fetch user organizations", e) @@ -91,4 +104,18 @@ class GithubProviderTokenServices implements SpinnakerProviderTokenServices { } return hasRequirements } + + private boolean hasNextPage(HttpHeaders headers) { + return headers.containsKey('link') ? nextLink(headers) != null : false + } + + private String nextPageUrl(String nextLink) { + def urlPart = nextLink.split(';')[0] + return urlPart.substring(1, urlPart.length() - 1) + } + + private String nextLink(HttpHeaders headers) { + String[] links = headers.getFirst('link').split(',') + return links.find { it.contains('rel="next"') } + } } diff --git a/gate-oauth2/src/test/groovy/com/netflix/spinnaker/gate/security/oauth2/provider/GithubProviderTokenServicesSpec.groovy b/gate-oauth2/src/test/groovy/com/netflix/spinnaker/gate/security/oauth2/provider/GithubProviderTokenServicesSpec.groovy new file mode 100644 index 0000000000..1e435f1479 --- /dev/null +++ b/gate-oauth2/src/test/groovy/com/netflix/spinnaker/gate/security/oauth2/provider/GithubProviderTokenServicesSpec.groovy @@ -0,0 +1,148 @@ +/* + * Copyright 2022 Armory, 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.gate.security.oauth2.provider + +import spock.lang.Specification +import spock.lang.Subject +import org.springframework.security.oauth2.client.OAuth2RestTemplate +import org.springframework.security.oauth2.client.OAuth2ClientContext +import org.springframework.security.oauth2.common.OAuth2AccessToken +import org.springframework.boot.autoconfigure.security.oauth2.resource.ResourceServerProperties +import com.netflix.spinnaker.gate.security.oauth2.provider.GithubProviderTokenServices.GithubRequirements +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity + +class GithubProviderTokenServicesSpec extends Specification { + + def 'should find org membership for single-page API response'() { + setup: + def sso = Mock(ResourceServerProperties) + sso.getClientId() >> 'testClientId' + def requirements = new GithubRequirements() + requirements.organization = 'testOrg' + def restTemplate = Mock(OAuth2RestTemplate) + def clientContext = Mock(OAuth2ClientContext) + restTemplate.getOAuth2ClientContext() >> clientContext + clientContext.getAccessToken() >> Mock(OAuth2AccessToken) + @Subject tokenServices = new GithubProviderTokenServices(sso: sso, requirements: requirements) + tokenServices.restTemplate = restTemplate + + when: 'the user orgs can be contained in a single-page API response' + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + def responseEntity = new ResponseEntity>>([['login': 'testOrg']], headers, HttpStatus.OK); + restTemplate.getForEntity('https://github.com/api/v3/users/1234/orgs', List.class) >> responseEntity + + and: 'an API request is made' + boolean isMember = tokenServices.checkOrganization('testToken', 'https://github.com/api/v3/users/1234/orgs', 'testOrg') + + then: 'the organization membership is found' + assert isMember + } + + def 'should not find org membership for single-page API response'() { + setup: + def sso = Mock(ResourceServerProperties) + sso.getClientId() >> 'testClientId' + def requirements = new GithubRequirements() + requirements.organization = 'testOrg' + def restTemplate = Mock(OAuth2RestTemplate) + def clientContext = Mock(OAuth2ClientContext) + restTemplate.getOAuth2ClientContext() >> clientContext + clientContext.getAccessToken() >> Mock(OAuth2AccessToken) + @Subject tokenServices = new GithubProviderTokenServices(sso: sso, requirements: requirements) + tokenServices.restTemplate = restTemplate + + when: 'the user orgs can be contained in a single-page API response' + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + def response = new ResponseEntity>>([['login': 'otherOrg']], headers, HttpStatus.OK); + restTemplate.getForEntity('https://github.com/api/v3/users/1234/orgs', List.class) >> response + + and: 'an API request is made' + boolean isMember = tokenServices.checkOrganization('testToken', 'https://github.com/api/v3/users/1234/orgs', 'testOrg') + + then: 'the organization membership is not found' + assert !isMember + } + + def 'should find org membership for multi-page API response'() { + setup: + def sso = Mock(ResourceServerProperties) + sso.getClientId() >> 'testClientId' + def requirements = new GithubRequirements() + requirements.organization = 'testOrg' + def restTemplate = Mock(OAuth2RestTemplate) + def clientContext = Mock(OAuth2ClientContext) + restTemplate.getOAuth2ClientContext() >> clientContext + clientContext.getAccessToken() >> Mock(OAuth2AccessToken) + @Subject tokenServices = new GithubProviderTokenServices(sso: sso, requirements: requirements) + tokenServices.restTemplate = restTemplate + + when: 'the user orgs are contained in a multi-page API response' + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.add('Link', '; rel="next", ; rel="last"') + def firstResponse = new ResponseEntity>>([['login': 'otherOrg']], headers, HttpStatus.OK); + restTemplate.getForEntity('https://github.com/api/v3/users/1234/orgs', List.class) >> firstResponse + def secondResponse = new ResponseEntity>>([['login': 'testOrg']], headers, HttpStatus.OK); + restTemplate.getForEntity('https://github.com/api/v3/users/1234/orgs?page=2', List.class) >> secondResponse + + and: 'API requests are made' + boolean isMember = tokenServices.checkOrganization('testToken', 'https://github.com/api/v3/users/1234/orgs', 'testOrg') + + then: 'the organization membership is found' + assert isMember + } + + def 'should not find org membership for multi-page API response'() { + setup: + def sso = Mock(ResourceServerProperties) + sso.getClientId() >> 'testClientId' + def requirements = new GithubRequirements() + requirements.organization = 'testOrg' + def restTemplate = Mock(OAuth2RestTemplate) + def clientContext = Mock(OAuth2ClientContext) + restTemplate.getOAuth2ClientContext() >> clientContext + clientContext.getAccessToken() >> Mock(OAuth2AccessToken) + @Subject tokenServices = new GithubProviderTokenServices(sso: sso, requirements: requirements) + tokenServices.restTemplate = restTemplate + + when: 'the user orgs are contained in a multi-page API response' + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.add('Link', '; rel="next", ; rel="last"') + def firstResponse = new ResponseEntity>>([['login': 'otherOrg']], headers, HttpStatus.OK); + restTemplate.getForEntity('https://github.com/api/v3/users/1234/orgs', List.class) >> firstResponse + headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.add('Link', '; rel="last", ; rel="first"') + def secondResponse = new ResponseEntity>>([['login': 'anotherOrg']], headers, HttpStatus.OK); + restTemplate.getForEntity('https://github.com/api/v3/users/1234/orgs?page=2', List.class) >> secondResponse + + and: 'API requests are made' + boolean isMember = tokenServices.checkOrganization('testToken', 'https://github.com/api/v3/users/1234/orgs', 'testOrg') + + then: 'the organization membership is found' + assert !isMember + } + + +}