Skip to content

Commit

Permalink
New feature UMA authorization for syncStations
Browse files Browse the repository at this point in the history
  • Loading branch information
clezag committed Mar 14, 2024
1 parent a108184 commit 90b5cf8
Show file tree
Hide file tree
Showing 13 changed files with 376 additions and 29 deletions.
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ KEYCLOAK_URL=https://auth.opendatahub.testingmachine.eu/auth
KEYCLOAK_SSL_REQUIRED=none
KEYCLOAK_REALM=noi
KEYCLOAK_CLIENT_ID=odh-mobility-writer-development
KEYCLOAK_CLIENT_SECRET=a0c41578-7f31-4b52-8efe-fab8aece34da
KEYCLOAK_LOG_LEVEL=WARN

### Database (see persistence.xml for details; .properties values override .xml values)
# Use localhost:5555 for development on your local host
Expand Down
32 changes: 32 additions & 0 deletions authorization.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# SPDX-FileCopyrightText: NOI Techpark <digital@noi.bz.it>
# SPDX-License-Identifier: CC0-1.0
#
##### VSCODE / REST Client
# Create a .env file and set the corresponding variables
# See all $dotenv fields below

@host = https://auth.opendatahub.testingmachine.eu/auth/realms/noi

### Get access token for UMA tests
# @name login
POST {{host}}/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded

&grant_type=client_credentials
&client_id=odh-mobility-dev-uma
&client_secret=STFLg69cUgZS8nNgtUQIuZDMiPQBIipe
&scope=openid

###
@authtoken = {{login.response.body.access_token}}

# See here for documentation of the API: https://www.keycloak.org/docs/23.0.7/authorization_services/#_service_overview

### Get endpoints and capabilities
GET {{host}}/.well-known/uma2-configuration

### Get permissions
GET {{host}}/authz/protection/resource_set
Authorization: Bearer {{authtoken}}

&grant_type=urn:ietf:params:oauth:grant-type:uma-ticket
20 changes: 17 additions & 3 deletions calls.http
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ grant_type=client_credentials
&client_secret=7bd46f8f-c296-416d-a13d-dc81e68d0830
&scope=openid

### Get access token for UMA tests
# @name login
#
POST https://auth.opendatahub.testingmachine.eu/auth/realms/noi/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=odh-mobility-dev-uma
&client_secret=STFLg69cUgZS8nNgtUQIuZDMiPQBIipe
&scope=openid

### Get access token for the writer (TEST DB)
# @name login
POST https://auth.opendatahub.testingmachine.eu/auth/realms/noi/protocol/openid-connect/token
Expand Down Expand Up @@ -96,6 +107,7 @@ POST {{host}}/json/syncStations/EChargingStation34
&prv=11111
&syncState=true
&onlyActivation=true
&origin=Test123
Content-Type: application/json
Authorization: Bearer {{authtoken}}

Expand Down Expand Up @@ -198,17 +210,19 @@ Authorization: Bearer {{authtoken}}
]

### Sync stations
POST {{host}}/json/syncStations/TestStations
POST {{host}}/json/syncStations/testtype
?prn=test
&prv=11111
&syncState=false
&onlyActivation=false
Content-Type: application/json
Authorization: Bearer {{authtoken}}

[
{
"id": "example-station-id-1",
"name": "example-station-name-1",
"origin": "docs-example",
"origin": "testorigin",
"latitude": 46.333,
"longitude": 11.356,
"municipality": "Bolzano",
Expand All @@ -219,7 +233,7 @@ Authorization: Bearer {{authtoken}}
{
"id": "example-station-id-2",
"name": "example-station-name-2",
"origin": "docs-example",
"origin": "testorigin",
"latitude": 46.333,
"longitude": 11.356,
"municipality": "Bolzano",
Expand Down
6 changes: 4 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ services:
- "${SERVER_PORT}:${SERVER_PORT}"
- 8990:8990
volumes:
# comment this line, if you don't want to write to your local maven repos
- ~/.m2/:/var/maven/.m2
- maven-cache:/var/maven/.m2
- ./:/code
working_dir: /code
tty: true
Expand Down Expand Up @@ -80,3 +79,6 @@ services:
DB_PASSWORD: password
ports:
- "8991:8991"

volumes:
maven-cache:
14 changes: 14 additions & 0 deletions writer/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ SPDX-License-Identifier: CC0-1.0
<finalName>writer</finalName>
<geotools.version>30.0</geotools.version>
<hibernate.version>6.3.1.Final</hibernate.version>
<keycloak.version>23.0.7</keycloak.version>
<!--keycloak.version>999.0.0-SNAPSHOT</keycloak.version-->
</properties>

<dependencyManagement>
Expand Down Expand Up @@ -70,6 +72,18 @@ SPDX-License-Identifier: CC0-1.0
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<!-- Keycloak UMA To request Resource level authorization -->
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-authz-client</artifactId>
<version>${keycloak.version}</version>
</dependency>

<!-- To create JSON schema descriptions out of classes -->
<dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@

import java.util.Map;

import jakarta.persistence.PersistenceException;
import jakarta.servlet.http.HttpServletRequest;

import org.hibernate.PropertyValueException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand All @@ -21,9 +18,11 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.module.jsonSchema.JsonSchema;
import com.fasterxml.jackson.module.jsonSchema.factories.SchemaFactoryWrapper;

import com.opendatahub.timeseries.bdp.writer.dal.util.JPAException;
import com.opendatahub.timeseries.bdp.dto.dto.ExceptionDto;
import com.opendatahub.timeseries.bdp.writer.dal.util.JPAException;

import jakarta.persistence.PersistenceException;
import jakarta.servlet.http.HttpServletRequest;

/**
* Catch and handle various exceptions. We use this to provide an unique representation of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import com.opendatahub.timeseries.bdp.writer.dal.util.JPAException;
import com.opendatahub.timeseries.bdp.writer.writer.authz.AuthorizeSyncStation;
import com.opendatahub.timeseries.bdp.dto.dto.DataMapDto;
import com.opendatahub.timeseries.bdp.dto.dto.DataTypeDto;
import com.opendatahub.timeseries.bdp.dto.dto.EventDto;
Expand Down Expand Up @@ -175,6 +176,7 @@ public ResponseEntity<Object> syncStations(
@RequestParam(value = "syncState", required = false, defaultValue = "true") Boolean syncState,
@RequestParam(value = "onlyActivation", required = false, defaultValue = "false") Boolean onlyActivation
) {
AuthorizeSyncStation.authorize(request, stationType, stationDtos, syncState, onlyActivation);
return dataManager.syncStations(
stationType,
stationDtos,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: NOI Techpark <digital@noi.bz.it>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
package com.opendatahub.timeseries.bdp.writer.writer.authz;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import org.keycloak.authorization.client.AuthorizationDeniedException;
import org.keycloak.authorization.client.AuthzClient;
import org.keycloak.authorization.client.resource.AuthorizationResource;
import org.keycloak.representations.idm.authorization.AuthorizationRequest;
import org.keycloak.representations.idm.authorization.Permission;
import org.keycloak.representations.idm.authorization.ResourceRepresentation;
import org.springframework.cache.annotation.Cacheable;

public class Authorization {
public static final String ATTRIBUTE_AUTHORIZATION = "bdp_authz";

private AuthzClient authz;
private AuthorizationResource authzRes;
private String clientId;

public Authorization(AuthzClient client, String accessToken) {
this.authz = client;
this.authzRes = client.authorization(accessToken); // Authorization resource initialized with the requestor's credentials
this.clientId = authz.getConfiguration().getResource();
}

public boolean hasAnyAuthorization() {
try {
authzRes.authorize();
return true;
} catch (AuthorizationDeniedException e) {
return false;
}
}

public List<ResourceRepresentation> getAuthorizedResources(String type, String scope) {
// https://github.com/keycloak/keycloak/issues/27483
var req = new AuthorizationRequest();
req.setMetadata(new AuthorizationRequest.Metadata());
req.setAudience(clientId);
req.setScope(scope);

var perms = authzRes.getPermissions(req);

Set<String> resourceIds;
// https://github.com/keycloak/keycloak/issues/16520
// runtime type is wrong until fix is merged
if (!perms.isEmpty() && ((List) perms).get(0) instanceof Map) {
// Forcefully cast this to map (which it actually is at runtime)
List<Object> tmp = new ArrayList<>(perms);
resourceIds = tmp.stream()
.map(o -> (Map<String, String>) o)
.map(m -> m.get("rsid"))
.collect(Collectors.toSet());
} else {
resourceIds = perms.stream().map(Permission::getResourceId).collect(Collectors.toSet());
}

return getAllResources().stream()
.filter(r -> resourceIds.contains(r.getId()))
.toList();
}

@Cacheable("resources")
private List<ResourceRepresentation> getAllResources() {
// get all resource details from Keycloak
// TODO: caching
var protect = authz.protection();
return protect.resource().<List<ResourceRepresentation>>find(null, null, null, null, null, null, false, true,
null, null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// SPDX-FileCopyrightText: NOI Techpark <digital@noi.bz.it>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
//
package com.opendatahub.timeseries.bdp.writer.writer.authz;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.function.BooleanSupplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;

import com.opendatahub.timeseries.bdp.dto.dto.StationDto;

import jakarta.servlet.http.HttpServletRequest;

public class AuthorizeSyncStation {
private static final Logger log = LoggerFactory.getLogger(AuthorizeSyncStation.class);

public static void authorize(HttpServletRequest req, String stationType, List<StationDto> dtos, boolean syncState,
boolean onlyActivation) {
var authz = (Authorization) req.getAttribute(Authorization.ATTRIBUTE_AUTHORIZATION);
if (authz == null) {
log.warn("No UMA authorization configuration found. Maybe your client credentials are incomplete or incorrect?");
throw new NotAuthorizedException("Not authorized");
}
log.debug("Start authorizing station sync");

var origins = dtos.stream()
.map(StationDto::getOrigin)
.collect(Collectors.toSet());

// Technically we could handle multiple origins, but the state synchronization
// doesn't behave correctly anyway
if (origins.size() != 1) {
throw new NotAuthorizedException("mixed or missing origins");
}
var origin = origins.stream().findFirst().get();

log.debug("Start evaluating UMA for stationType = {}, origin = {}, syncState = {}, onlyActivation = {}", stationType, origin, syncState, onlyActivation);

var authorizedResources = authz.getAuthorizedResources("station", "write");
log.debug("Got authorized resources from server: {}", authorizedResources);

boolean authorized = authorizedResources.stream()
.flatMap(r -> r.getUris().stream())
.filter(u -> uriMatches(u, stationType, origin, syncState, onlyActivation))
.findAny().isPresent();

log.debug("Authorization on resource granted: {}", authorized);

if (!authorized){
throw new NotAuthorizedException("Missing authorization");
}
}

private record Test(String name, BooleanSupplier condition){}

public static boolean uriMatches(String uri, String stationType, String origin, boolean syncState, boolean onlyActivation) {
var u = UriComponentsBuilder.fromUriString(uri).build();

log.debug("Checking URI {}", u);
return Arrays.stream(new Test[] {
new Test("scheme", () -> "bdp".equals(u.getScheme())),
new Test("authority", () -> "station".equals(u.getHost())),
new Test("stationType", () -> getQueryParam(u, "stationType").anyMatch(s -> s.equals(stationType))),
new Test("origin", () -> getQueryParam(u, "origin").anyMatch(s -> s.equals(origin))),
new Test("syncState", () -> getQueryParam(u, "syncState").map(Boolean::parseBoolean).anyMatch(b -> b == syncState)),
new Test("onlyActivation", () -> getQueryParam(u, "onlyActivation").map(Boolean::parseBoolean).anyMatch(b -> b == onlyActivation))
})
.allMatch(t -> {
boolean result = t.condition.getAsBoolean();
log.debug("Check {}: {}", t.name, result);
return result;
});
}

private static Stream<String> getQueryParam(UriComponents u, String param){
return u.getQueryParams().getOrDefault(param, Collections.emptyList()).stream();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: NOI Techpark <digital@noi.bz.it>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
package com.opendatahub.timeseries.bdp.writer.writer.authz;

import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;

public class NotAuthorizedException extends ResponseStatusException {
public NotAuthorizedException(String reason) {
super(HttpStatus.UNAUTHORIZED, reason);
}
}
Loading

0 comments on commit 90b5cf8

Please sign in to comment.