diff --git a/backoffice/backoffice-domain-service/ballerina/types.bal b/backoffice/backoffice-domain-service/ballerina/types.bal index 62114c4b6..fb5754b7f 100644 --- a/backoffice/backoffice-domain-service/ballerina/types.bal +++ b/backoffice/backoffice-domain-service/ballerina/types.bal @@ -1,6 +1,5 @@ // AUTO-GENERATED FILE. // This file is auto-generated by the Ballerina OpenAPI tool. - import ballerina/constraint; import ballerina/http; @@ -392,7 +391,7 @@ public type APIInfo record { string updatedTime?; boolean hasThumbnail?; # State of the API. Only published APIs are visible on the Developer Portal - "CREATED"|"PUBLISHED" state?; + string state?; }; public type APIExternalStore record { diff --git a/idp/idp-domain-service/ballerina/IdpConstants.bal b/idp/idp-domain-service/ballerina/IdpConstants.bal index e32369e9f..6e2530f29 100644 --- a/idp/idp-domain-service/ballerina/IdpConstants.bal +++ b/idp/idp-domain-service/ballerina/IdpConstants.bal @@ -35,6 +35,7 @@ const string TOKEN_TYPE_CLAIM = "token_type"; const string TOKEN_TYPE_BEARER = "Bearer"; const string REDIRECT_URI_CLAIM = "redirectUri"; const string SCOPES_CLAIM = "scope"; +const string GROUPS_CLAIM = "groups"; const string SESSION_KEY_PREFIX = "session-"; const string STATE_KEY_QUERY_PARAM = "stateKey"; const string LOCATION_HEADER = "Location"; diff --git a/idp/idp-domain-service/ballerina/Oauth2Types.bal b/idp/idp-domain-service/ballerina/Oauth2Types.bal index df0c4107b..6e2fb0531 100644 --- a/idp/idp-domain-service/ballerina/Oauth2Types.bal +++ b/idp/idp-domain-service/ballerina/Oauth2Types.bal @@ -76,6 +76,8 @@ public type Token_body record { string refresh_token?; # OAuth scopes string scope?; + # Visibility groups + string groups?; # username string username?; # password diff --git a/idp/idp-domain-service/ballerina/TokenUtil.bal b/idp/idp-domain-service/ballerina/TokenUtil.bal index 38e480164..15d619ca8 100644 --- a/idp/idp-domain-service/ballerina/TokenUtil.bal +++ b/idp/idp-domain-service/ballerina/TokenUtil.bal @@ -66,7 +66,7 @@ public class TokenUtil { } else if grantType == AUTHORIZATION_CODE_GRANT_TYPE { return self.handleAuthorizationCodeGrant(payload, application); } else if grantType == REFRESH_TOKEN_GRANT_TYPE { - return self.hanleRefreshTokenGrant(payload, application); + return self.handleRefreshTokenGrant(payload, application); } else { BadRequestTokenErrorResponse tokenError = {body: {'error: "unsupported_grant_type", error_description: grantType + " not supported by system."}}; return tokenError; @@ -86,7 +86,8 @@ public class TokenUtil { } public isolated function handleClientCredentialsGrant(Token_body payload, Application application) returns OkTokenResponse|BadRequestTokenErrorResponse|UnauthorizedTokenErrorResponse { string[] scopeArray = self.filterScopes(payload.scope); - string|jwt:Error tokenResult = self.issueToken(application, (), scopeArray, (), ACCESS_TOKEN_TYPE); + string[] groupsArray = self.filterGroups(payload.groups); + string|jwt:Error tokenResult = self.issueToken(application, (), scopeArray, groupsArray, (), ACCESS_TOKEN_TYPE); if tokenResult is string { TokenResponse tokenResponse = { access_token: tokenResult, @@ -103,7 +104,7 @@ public class TokenUtil { } } - public isolated function issueToken(Application application, string? username, string[] scopes, string? organization, string tokenType) returns string|jwt:Error { + public isolated function issueToken(Application application, string? username, string[] scopes, string[] groups, string? organization, string tokenType) returns string|jwt:Error { TokenIssuerConfiguration issuerConfiguration = idpConfiguration.tokenIssuerConfiguration; string jwtid = uuid:createType1AsString(); decimal exptime = tokenType == ACCESS_TOKEN_TYPE ? issuerConfiguration.expTime : issuerConfiguration.refrshTokenValidity; @@ -124,6 +125,7 @@ public class TokenUtil { map customClaims = {}; customClaims[CLIENT_ID_CLAIM] = application.client_id; customClaims[SCOPES_CLAIM] = string:'join(" ", ...scopes); + customClaims[GROUPS_CLAIM] = string:'join(" ", ...groups); if organization is string && organization.toString().trim().length() > 0 { customClaims[ORGANIZATION_CLAIM] = organization; } @@ -157,6 +159,7 @@ public class TokenUtil { string requestRedirectUrl = validatedPayload.get(REDIRECT_URI_CLAIM); string clientId = validatedPayload.get(CLIENT_ID_CLAIM); json[] scopes = validatedPayload.get(SCOPES_CLAIM); + json[] groups = validatedPayload.hasKey(GROUPS_CLAIM) ? validatedPayload.get(GROUPS_CLAIM) : []; string sub = validatedPayload.sub; string[]? redirectUris = application.redirect_uris; @@ -169,9 +172,14 @@ public class TokenUtil { foreach json scope in scopes { scopesArray.push(scope.toString()); } + + string[] groupsArray = []; + foreach json group in groups { + groupsArray.push(group.toString()); + } do { - string accessToken = check self.issueToken(application, sub, scopesArray, organization, ACCESS_TOKEN_TYPE); - string refreshToken = check self.issueToken(application, sub, scopesArray, organization, REFRESH_TOKEN_TYPE); + string accessToken = check self.issueToken(application, sub, scopesArray, groupsArray, organization, ACCESS_TOKEN_TYPE); + string refreshToken = check self.issueToken(application, sub, scopesArray, groupsArray, organization, REFRESH_TOKEN_TYPE); TokenResponse token = {access_token: accessToken, refresh_token: refreshToken, expires_in: idpConfiguration.tokenIssuerConfiguration.expTime, token_type: TOKEN_TYPE_BEARER, scope: string:'join(" ", ...scopesArray)}; return {body: token}; } on fail var e { @@ -184,7 +192,7 @@ public class TokenUtil { return tokenError; } } - public isolated function hanleRefreshTokenGrant(Token_body payload, Application application) returns BadRequestTokenErrorResponse|OkTokenResponse { + public isolated function handleRefreshTokenGrant(Token_body payload, Application application) returns BadRequestTokenErrorResponse|OkTokenResponse { string? refresh_token = payload.refresh_token; if (refresh_token is () || refresh_token.toString().trim().length() == 0) { @@ -206,6 +214,8 @@ public class TokenUtil { } string clientId = validatedPayload.get(CLIENT_ID_CLAIM); string scopes = validatedPayload.get(SCOPES_CLAIM); + string groups = validatedPayload.get(GROUPS_CLAIM); + string sub = validatedPayload.sub; string? organization = validatedPayload.hasKey(ORGANIZATION_CLAIM) ? validatedPayload.get(ORGANIZATION_CLAIM) : (); @@ -215,8 +225,9 @@ public class TokenUtil { } do { string[] scopesArray = regex:split(scopes, " "); - string accessToken = check self.issueToken(application, sub, scopesArray, organization, ACCESS_TOKEN_TYPE); - string refreshToken = check self.issueToken(application, sub, scopesArray, organization, REFRESH_TOKEN_TYPE); + string[] groupsArray = regex:split(groups, " "); + string accessToken = check self.issueToken(application, sub, scopesArray, groupsArray, organization, ACCESS_TOKEN_TYPE); + string refreshToken = check self.issueToken(application, sub, scopesArray, groupsArray, organization, REFRESH_TOKEN_TYPE); TokenResponse token = {access_token: accessToken, refresh_token: refreshToken, expires_in: idpConfiguration.tokenIssuerConfiguration.expTime, token_type: TOKEN_TYPE_BEARER, scope: scopes}; return {body: token}; } on fail var e { @@ -303,6 +314,14 @@ public class TokenUtil { } return scopeArray; } + + public isolated function filterGroups(string? groups) returns string[] { + string[] groupArray = []; + if groups is string && groups.trim().length() > 0 { + groupArray = regex:split(groups, " "); + } + return groupArray; + } public isolated function handleOauthCallBackRequest(http:Request request, string sessionKey) returns http:Found { do { diff --git a/test/cucumber-tests/src/test/java/org/wso2/apk/integration/api/BackOfficeSteps.java b/test/cucumber-tests/src/test/java/org/wso2/apk/integration/api/BackOfficeSteps.java new file mode 100644 index 000000000..6c4d2c6b4 --- /dev/null +++ b/test/cucumber-tests/src/test/java/org/wso2/apk/integration/api/BackOfficeSteps.java @@ -0,0 +1,33 @@ +package org.wso2.apk.integration.api; + +import java.util.HashMap; +import java.util.Map; +import io.cucumber.java.en.When; + +import org.apache.http.HttpResponse; +import org.wso2.apk.integration.utils.Constants; +import org.wso2.apk.integration.utils.Utils; +import org.wso2.apk.integration.utils.clients.SimpleHTTPClient; + +public class BackOfficeSteps { + + private final SharedContext sharedContext; + + public BackOfficeSteps(SharedContext sharedContext) { + + this.sharedContext = sharedContext; + } + + @When("I make the GET APIs call to the backoffice") + public void make_a_deployment_request() throws Exception { + Map headers = new HashMap<>(); + headers.put(Constants.REQUEST_HEADERS.AUTHORIZATION, "Bearer " + sharedContext.getAccessToken()); + headers.put(Constants.REQUEST_HEADERS.HOST, Constants.DEFAULT_API_HOST); + + HttpResponse response = sharedContext.getHttpClient().doGet(Utils.getBackOfficeAPIURL(), + headers); + + sharedContext.setResponse(response); + sharedContext.setResponseBody(SimpleHTTPClient.responseEntityBodyToString(sharedContext.getResponse())); + } +} diff --git a/test/cucumber-tests/src/test/java/org/wso2/apk/integration/api/BaseSteps.java b/test/cucumber-tests/src/test/java/org/wso2/apk/integration/api/BaseSteps.java index 1ba9414be..6f78d23f3 100644 --- a/test/cucumber-tests/src/test/java/org/wso2/apk/integration/api/BaseSteps.java +++ b/test/cucumber-tests/src/test/java/org/wso2/apk/integration/api/BaseSteps.java @@ -94,18 +94,22 @@ public void systemIsReady() { @Then("the response body should contain {string}") public void theResponseBodyShouldContain(String expectedText) throws IOException { - Assert.assertTrue(sharedContext.getResponseBody().contains(expectedText), "Actual response body: " + sharedContext.getResponseBody()); + Assert.assertTrue(sharedContext.getResponseBody().contains(expectedText), + "Actual response body: " + sharedContext.getResponseBody()); } + @Then("the response body should not contain {string}") public void theResponseBodyShouldNotContain(String expectedText) throws IOException { - Assert.assertFalse(sharedContext.getResponseBody().contains(expectedText), "Actual response body: " + sharedContext.getResponseBody()); + Assert.assertFalse(sharedContext.getResponseBody().contains(expectedText), + "Actual response body: " + sharedContext.getResponseBody()); } @Then("the response body should contain") public void theResponseBodyShouldContain(DataTable dataTable) throws IOException { List responseBodyLines = dataTable.asList(String.class); for (String line : responseBodyLines) { - Assert.assertTrue(sharedContext.getResponseBody().contains(line), "Actual response body: " + sharedContext.getResponseBody()); + Assert.assertTrue(sharedContext.getResponseBody().contains(line), + "Actual response body: " + sharedContext.getResponseBody()); } } @@ -141,7 +145,8 @@ public void sendHttpRequest(String httpMethod, String url, String body) throws I // It will send request using a new thread and forget about the response @Then("I send {string} async request to {string} with body {string}") - public void sendAsyncHttpRequest(String httpMethod, String url, String body) throws IOException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException { + public void sendAsyncHttpRequest(String httpMethod, String url, String body) + throws IOException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException { String finalBody = Utils.resolveVariables(body, sharedContext.getValueStore()); if (sharedContext.getResponse() instanceof CloseableHttpResponse) { ((CloseableHttpResponse) sharedContext.getResponse()).close(); @@ -222,7 +227,7 @@ public void waitForNextMinute() throws InterruptedException { if (secondsToWait > MAX_WAIT_FOR_NEXT_MINUTE_IN_SECONDS) { return; } - Thread.sleep((secondsToWait+1) * 1000); + Thread.sleep((secondsToWait + 1) * 1000); logger.info("Current time: " + LocalDateTime.now()); } @@ -231,7 +236,7 @@ public void waitForNextMinuteStrictly() throws InterruptedException { LocalDateTime now = LocalDateTime.now(); LocalDateTime nextMinute = now.plusMinutes(1).withSecond(0).withNano(0); long secondsToWait = now.until(nextMinute, ChronoUnit.SECONDS); - Thread.sleep((secondsToWait+1) * 1000); + Thread.sleep((secondsToWait + 1) * 1000); logger.info("Current time: " + LocalDateTime.now()); } @@ -261,8 +266,9 @@ public void containsHeader(String key, String value) { return; // Any value is acceptable } String actualValue = header.getValue(); - Assert.assertEquals(value, actualValue,"Header with key found but value mismatched."); + Assert.assertEquals(value, actualValue, "Header with key found but value mismatched."); } + @Then("the response headers not contains key {string}") public void notContainsHeader(String key) { key = Utils.resolveVariables(key, sharedContext.getValueStore()); @@ -271,11 +277,12 @@ public void notContainsHeader(String key) { Assert.fail("Response is null."); } Header header = response.getFirstHeader(key); - Assert.assertNull(header,"header contains in response headers"); + Assert.assertNull(header, "header contains in response headers"); } @Then("the {string} jwt should validate from JWKS {string} and contain") - public void decode_header_and_validate(String header,String jwksEndpoint, DataTable dataTable) throws MalformedURLException { + public void decode_header_and_validate(String header, String jwksEndpoint, DataTable dataTable) + throws MalformedURLException { List> claims = dataTable.asMaps(String.class, String.class); JsonObject jsonResponse = (JsonObject) JsonParser.parseString(sharedContext.getResponseBody()); String headerValue = jsonResponse.get("headers").getAsJsonObject().get(header).getAsString(); @@ -310,7 +317,7 @@ public void decode_header_and_validate(String header,String jwksEndpoint, DataTa Assert.assertEquals(claim.get("value"), claim1.toString(), "Actual " + "decoded JWT body: " + claimsSet); } - } catch (BadJOSEException | JOSEException|ParseException e) { + } catch (BadJOSEException | JOSEException | ParseException e) { logger.error("JWT Signature verification fail", e); Assert.fail("JWT Signature verification fail"); } @@ -321,9 +328,11 @@ public void iHaveValidSubscription() throws Exception { Map headers = new HashMap<>(); headers.put(Constants.REQUEST_HEADERS.HOST, Constants.DEFAULT_IDP_HOST); - headers.put(Constants.REQUEST_HEADERS.AUTHORIZATION, "Basic NDVmMWM1YzgtYTkyZS0xMWVkLWFmYTEtMDI0MmFjMTIwMDAyOjRmYmQ2MmVjLWE5MmUtMTFlZC1hZmExLTAyNDJhYzEyMDAwMg=="); + headers.put(Constants.REQUEST_HEADERS.AUTHORIZATION, + "Basic NDVmMWM1YzgtYTkyZS0xMWVkLWFmYTEtMDI0MmFjMTIwMDAyOjRmYmQ2MmVjLWE5MmUtMTFlZC1hZmExLTAyNDJhYzEyMDAwMg=="); - HttpResponse httpResponse = httpClient.doPost(Utils.getTokenEndpointURL(), headers, "grant_type=client_credentials&scope=" + Constants.API_CREATE_SCOPE, + HttpResponse httpResponse = httpClient.doPost(Utils.getTokenEndpointURL(), headers, + "grant_type=client_credentials&scope=" + Constants.API_CREATE_SCOPE, Constants.CONTENT_TYPES.APPLICATION_X_WWW_FORM_URLENCODED); sharedContext.setAccessToken(Utils.extractToken(httpResponse)); sharedContext.addStoreValue("accessToken", sharedContext.getAccessToken()); @@ -334,9 +343,11 @@ public void iHaveValidSubscriptionWithAPICreateScope() throws Exception { Map headers = new HashMap<>(); headers.put(Constants.REQUEST_HEADERS.HOST, Constants.DEFAULT_IDP_HOST); - headers.put(Constants.REQUEST_HEADERS.AUTHORIZATION, "Basic NDVmMWM1YzgtYTkyZS0xMWVkLWFmYTEtMDI0MmFjMTIwMDAyOjRmYmQ2MmVjLWE5MmUtMTFlZC1hZmExLTAyNDJhYzEyMDAwMg=="); + headers.put(Constants.REQUEST_HEADERS.AUTHORIZATION, + "Basic NDVmMWM1YzgtYTkyZS0xMWVkLWFmYTEtMDI0MmFjMTIwMDAyOjRmYmQ2MmVjLWE5MmUtMTFlZC1hZmExLTAyNDJhYzEyMDAwMg=="); - HttpResponse httpResponse = httpClient.doPost(Utils.getTokenEndpointURL(), headers, "grant_type=client_credentials", + HttpResponse httpResponse = httpClient.doPost(Utils.getTokenEndpointURL(), headers, + "grant_type=client_credentials", Constants.CONTENT_TYPES.APPLICATION_X_WWW_FORM_URLENCODED); sharedContext.setAccessToken(Utils.extractToken(httpResponse)); sharedContext.addStoreValue("accessToken", sharedContext.getAccessToken()); @@ -355,9 +366,28 @@ public void iHaveValidSubscriptionWithScope(DataTable dataTable) throws Exceptio headers.put(Constants.REQUEST_HEADERS.AUTHORIZATION, Constants.SUBSCRIPTION_BASIC_AUTH_TOKEN); HttpResponse httpResponse = httpClient.doPost(Utils.getTokenEndpointURL(), headers, - "grant_type=client_credentials&scope=" + scopes, - Constants.CONTENT_TYPES.APPLICATION_X_WWW_FORM_URLENCODED); + "grant_type=client_credentials&scope=" + scopes, + Constants.CONTENT_TYPES.APPLICATION_X_WWW_FORM_URLENCODED); sharedContext.setAccessToken(Utils.extractToken(httpResponse)); sharedContext.addStoreValue(Constants.ACCESS_TOKEN, sharedContext.getAccessToken()); } + + @Given("I have a valid subscription with groups") + public void iHaveValidSubscriptionWithGroups(DataTable dataTable) throws Exception { + List> rows = dataTable.asLists(String.class); + String groups = Constants.EMPTY_STRING; + for (List row : rows) { + String group = row.get(0); + groups += group + Constants.SPACE_STRING; + } + Map headers = new HashMap<>(); + headers.put(Constants.REQUEST_HEADERS.HOST, Constants.DEFAULT_IDP_HOST); + headers.put(Constants.REQUEST_HEADERS.AUTHORIZATION, Constants.SUBSCRIPTION_BASIC_AUTH_TOKEN); + + HttpResponse httpResponse = httpClient.doPost(Utils.getTokenEndpointURL(), headers, + "grant_type=client_credentials&scope=" + Constants.API_VIEW_SCOPE + "&groups=" + groups, + Constants.CONTENT_TYPES.APPLICATION_X_WWW_FORM_URLENCODED); + sharedContext.setAccessToken(Utils.extractToken(httpResponse)); + sharedContext.addStoreValue("accessToken", sharedContext.getAccessToken()); + } } diff --git a/test/cucumber-tests/src/test/java/org/wso2/apk/integration/utils/Constants.java b/test/cucumber-tests/src/test/java/org/wso2/apk/integration/utils/Constants.java index 8c543c8b0..b97da434e 100644 --- a/test/cucumber-tests/src/test/java/org/wso2/apk/integration/utils/Constants.java +++ b/test/cucumber-tests/src/test/java/org/wso2/apk/integration/utils/Constants.java @@ -25,12 +25,13 @@ public class Constants { public static final String DEFAULT_TOKEN_EP = "oauth2/token"; public static final String DEFAULT_API_CONFIGURATOR = "api/configurator/1.0.0/"; public static final String DEFAULT_API_DEPLOYER = "api/deployer/1.0.0/"; + public static final String DEFAULT_BACKOFFICE = "/api/backoffice/1.0.0/"; public static final String ACCESS_TOKEN = "accessToken"; public static final String EMPTY_STRING = ""; public static final String API_CREATE_SCOPE = "apk:api_create"; + public static final String API_VIEW_SCOPE = "apk:api_view"; public static final String SPACE_STRING = " "; - public static final String SUBSCRIPTION_BASIC_AUTH_TOKEN = - "Basic NDVmMWM1YzgtYTkyZS0xMWVkLWFmYTEtMDI0MmFjMTIwMDAyOjRmYmQ2MmVjLWE5MmUtMTFlZC1hZmExLTAyNDJhYzEyMDAwMg=="; + public static final String SUBSCRIPTION_BASIC_AUTH_TOKEN = "Basic NDVmMWM1YzgtYTkyZS0xMWVkLWFmYTEtMDI0MmFjMTIwMDAyOjRmYmQ2MmVjLWE5MmUtMTFlZC1hZmExLTAyNDJhYzEyMDAwMg=="; public class REQUEST_HEADERS { diff --git a/test/cucumber-tests/src/test/java/org/wso2/apk/integration/utils/Utils.java b/test/cucumber-tests/src/test/java/org/wso2/apk/integration/utils/Utils.java index 2097b61d0..dd18f791f 100644 --- a/test/cucumber-tests/src/test/java/org/wso2/apk/integration/utils/Utils.java +++ b/test/cucumber-tests/src/test/java/org/wso2/apk/integration/utils/Utils.java @@ -55,6 +55,12 @@ public static String getAPIDeployerURL() { + Constants.DEFAULT_API_DEPLOYER + "apis/deploy"; } + public static String getBackOfficeAPIURL() { + + return "https://" + Constants.DEFAULT_API_HOST + ":" + Constants.DEFAULT_GW_PORT + "/" + + Constants.DEFAULT_BACKOFFICE + "apis"; + } + public static String getAPIUnDeployerURL() { return "https://" + Constants.DEFAULT_API_HOST + ":" + Constants.DEFAULT_GW_PORT + "/" diff --git a/test/cucumber-tests/src/test/resources/tests/api/Visibility.feature b/test/cucumber-tests/src/test/resources/tests/api/Visibility.feature new file mode 100644 index 000000000..9aa0b289c --- /dev/null +++ b/test/cucumber-tests/src/test/resources/tests/api/Visibility.feature @@ -0,0 +1,16 @@ +# Feature: API Visibility and Access Control +# Scenario: View APIs with a groups claim and API view scope in the access token +# Given The system is ready +# And I have a valid subscription with groups +# | group1 | +# When I make the GET APIs call to the backoffice +# Then the response status code should be 200 +# And the response body should contain "\"count\":0" + +# Scenario: View APIs without a groups claim in the access token +# Given The system is ready +# And I have a valid subscription with scopes +# | apk:api_view | +# When I make the GET APIs call to the backoffice +# Then the response status code should be 200 +# And the response body should contain "\"count\":0"