Call to an "authorization agent" to get access control.
Change-Id: I95615144046c1bf90d4e8204d10a326d048273a4
Signed-off-by: PatrikBuhr <patrik.buhr@est.tech>
Issue-ID: NONRTRIC-815
"description": "Information for an EI type",
"type": "object"
},
+ "MonoResponseEntityObject": {"type": "object"},
+ "authorization_result": {
+ "description": "Result of authorization",
+ "type": "object",
+ "required": ["result"],
+ "properties": {"result": {
+ "description": "If true, the access is granted",
+ "type": "boolean"
+ }}
+ },
"service_status_info": {
"type": "object",
"required": [
}
}
},
+ "subscription_authorization": {
+ "description": "Authorization request for subscription requests",
+ "type": "object",
+ "required": ["input"],
+ "properties": {"input": {"$ref": "#/components/schemas/input"}}
+ },
"producer_info_type_info": {
"description": "Information for an Information Type",
"type": "object",
}
}
},
+ "input": {
+ "description": "input",
+ "type": "object",
+ "required": [
+ "access_type",
+ "auth_token",
+ "info_type_id",
+ "job_definition"
+ ],
+ "properties": {
+ "access_type": {
+ "description": "Access type",
+ "type": "string",
+ "enum": [
+ "READ",
+ "WRITE"
+ ]
+ },
+ "info_type_id": {
+ "description": "Information type identifier",
+ "type": "string"
+ },
+ "job_definition": {
+ "description": "Information type specific job data",
+ "type": "object"
+ },
+ "auth_token": {
+ "description": "Authorization token",
+ "type": "string"
+ }
+ }
+ },
"consumer_job": {
"description": "Information for an Information Job",
"type": "object",
"operationId": "deleteJobsForOwner",
"responses": {"204": {
"description": "No Content",
- "content": {"application/json": {"schema": {"type": "object"}}}
+ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MonoResponseEntityObject"}}}
}},
"parameters": [{
"schema": {"type": "string"},
}],
"tags": ["Data producer (registration)"]
}},
+ "/example-subscription-auth": {"post": {
+ "summary": "Request for access authorization.",
+ "requestBody": {
+ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/subscription_authorization"}}},
+ "required": true
+ },
+ "description": "The authorization function decides if access is granted.",
+ "operationId": "subscriptionAuth",
+ "responses": {"200": {
+ "description": "OK",
+ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/authorization_result"}}}
+ }},
+ "tags": ["Authorization API"]
+ }},
"/actuator/heapdump": {"get": {
"summary": "Actuator web endpoint 'heapdump'",
"operationId": "heapdump",
"description": "Spring Boot Actuator Web API Documentation",
"url": "https://docs.spring.io/spring-boot/docs/current/actuator-api/html/"
}
+ },
+ {
+ "name": "Authorization API",
+ "description": "API used for authorization of information job access (this is provided by an authorization producer such as OPA)"
}
]
}
\ No newline at end of file
externalDocs:
description: Spring Boot Actuator Web API Documentation
url: https://docs.spring.io/spring-boot/docs/current/actuator-api/html/
+- name: Authorization API
+ description: API used for authorization of information job access (this is provided
+ by an authorization producer such as OPA)
paths:
/data-producer/v1/info-types:
get:
content:
application/json:
schema:
- type: object
+ $ref: '#/components/schemas/MonoResponseEntityObject'
/actuator/loggers/{name}:
get:
tags:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
+ /example-subscription-auth:
+ post:
+ tags:
+ - Authorization API
+ summary: Request for access authorization.
+ description: The authorization function decides if access is granted.
+ operationId: subscriptionAuth
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/subscription_authorization'
+ required: true
+ responses:
+ 200:
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/authorization_result'
/actuator/heapdump:
get:
tags:
EiTypeObject:
type: object
description: Information for an EI type
+ MonoResponseEntityObject:
+ type: object
+ authorization_result:
+ required:
+ - result
+ type: object
+ properties:
+ result:
+ type: boolean
+ description: If true, the access is granted
+ description: Result of authorization
service_status_info:
required:
- no_of_jobs
type: object
description: EI type specific job data
description: Information for an Enrichment Information Job
+ subscription_authorization:
+ required:
+ - input
+ type: object
+ properties:
+ input:
+ $ref: '#/components/schemas/input'
+ description: Authorization request for subscription requests
producer_info_type_info:
required:
- info_job_data_schema
description: Type identity for the job
description: The body of the Information Producer callbacks for Information
Job creation and deletion
+ input:
+ required:
+ - access_type
+ - auth_token
+ - info_type_id
+ - job_definition
+ type: object
+ properties:
+ access_type:
+ type: string
+ description: Access type
+ enum:
+ - READ
+ - WRITE
+ info_type_id:
+ type: string
+ description: Information type identifier
+ job_definition:
+ type: object
+ description: Information type specific job data
+ auth_token:
+ type: string
+ description: Authorization token
+ description: input
consumer_job:
required:
- info_type_id
vardata-directory: /var/information-coordinator-service
# If the file name is empty, no authorization token is used
auth-token-file:
+ # A URL to a REST based service that provides authorization. This can for instance be Open Policy Agent, OPA
+ info-job-authorization-agent:
# S3 object store usage is enabled by defining the bucket to use. This will override the vardata-directory parameter.
s3:
endpointOverride: http://localhost:9000
package org.oransc.ics.configuration;
import lombok.Getter;
+import lombok.Setter;
import org.oransc.ics.configuration.WebClientConfig.HttpProxyConfig;
import org.slf4j.Logger;
@Value("${app.s3.bucket:}")
private String s3Bucket;
+ @Getter
+ @Setter
+ @Value("${app.info-job-authorization-agent:}")
+ private String authAgentUrl;
+
private WebClientConfig webClientConfig = null;
public WebClientConfig getWebClientConfig() {
import reactor.core.publisher.Mono;
public class ErrorResponse {
- private static Gson gson = new GsonBuilder().create();
+ private static Gson gson = new GsonBuilder().disableHtmlEscaping().create();
private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
// Returned as body for all failed REST calls
this.message = message;
}
+ public static Mono<ResponseEntity<Object>> createMono(Throwable e) {
+ return Mono.just(create(e, HttpStatus.INTERNAL_SERVER_ERROR));
+ }
+
public static Mono<ResponseEntity<Object>> createMono(Throwable e, HttpStatus code) {
return Mono.just(create(e, code));
}
public class A1eCallbacks {
private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
- private static Gson gson = new GsonBuilder().create();
+ private static Gson gson = new GsonBuilder().disableHtmlEscaping().create();
private final AsyncRestClient restClient;
private final InfoJobs eiJobs;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import org.json.JSONObject;
import org.oransc.ics.configuration.ApplicationConfig;
import org.oransc.ics.controllers.ErrorResponse;
import org.oransc.ics.controllers.VoidResponse;
+import org.oransc.ics.controllers.authorization.AuthorizationCheck;
+import org.oransc.ics.controllers.authorization.SubscriptionAuthRequest.Input.AccessType;
import org.oransc.ics.controllers.r1producer.ProducerCallbacks;
import org.oransc.ics.exceptions.ServiceException;
import org.oransc.ics.repository.InfoJob;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Autowired
ProducerCallbacks producerCallbacks;
- private static Gson gson = new GsonBuilder().create();
+ @Autowired
+ private AuthorizationCheck authorization;
+
+ private static Gson gson = new GsonBuilder().disableHtmlEscaping().create();
@GetMapping(path = "/eitypes", produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "EI type identifiers", description = "")
this.infoJobs.getJobs().forEach(job -> result.add(job.getId()));
}
return new ResponseEntity<>(gson.toJson(result), HttpStatus.OK);
- } catch (
-
- Exception e) {
+ } catch (Exception e) {
return ErrorResponse.create(e, HttpStatus.NOT_FOUND);
}
}
description = "Enrichment Information job is not found", //
content = @Content(schema = @Schema(implementation = ErrorResponse.ErrorInfo.class))) //
})
- public ResponseEntity<Object> getIndividualEiJob( //
- @PathVariable("eiJobId") String eiJobId) {
- try {
- InfoJob job = this.infoJobs.getJob(eiJobId);
- return new ResponseEntity<>(gson.toJson(toEiJobInfo(job)), HttpStatus.OK);
- } catch (Exception e) {
- return ErrorResponse.create(e, HttpStatus.NOT_FOUND);
- }
+ public Mono<ResponseEntity<Object>> getIndividualEiJob( //
+ @PathVariable("eiJobId") String eiJobId, //
+ @RequestHeader Map<String, String> headers) {
+
+ return this.infoJobs.getJobMono(eiJobId)
+ .flatMap(job -> authorization.authorizeDataJob(headers, job, AccessType.READ)) //
+ .map(job -> new ResponseEntity<Object>(gson.toJson(toEiJobInfo(job)), HttpStatus.OK))
+ .onErrorResume(ErrorResponse::createMono);
}
@GetMapping(path = "/eijobs/{eiJobId}/status", produces = MediaType.APPLICATION_JSON_VALUE)
description = "Enrichment Information job is not found", //
content = @Content(schema = @Schema(implementation = ErrorResponse.ErrorInfo.class))) //
})
- public ResponseEntity<Object> deleteIndividualEiJob( //
- @PathVariable("eiJobId") String eiJobId) {
- try {
- InfoJob job = this.infoJobs.getJob(eiJobId);
- this.infoJobs.remove(job, this.infoProducers);
- return new ResponseEntity<>(HttpStatus.NO_CONTENT);
- } catch (Exception e) {
- return ErrorResponse.create(e, HttpStatus.NOT_FOUND);
- }
+ public Mono<ResponseEntity<Object>> deleteIndividualEiJob( //
+ @PathVariable("eiJobId") String eiJobId, //
+ @RequestHeader Map<String, String> headers) {
+
+ return this.infoJobs.getJobMono(eiJobId)
+ .flatMap(job -> authorization.authorizeDataJob(headers, job, AccessType.WRITE)) //
+ .doOnNext(job -> this.infoJobs.remove(job, this.infoProducers))
+ .map(x -> new ResponseEntity<>(HttpStatus.NO_CONTENT)).onErrorResume(ErrorResponse::createMono);
}
@PutMapping(
@ApiResponse(
responseCode = "409",
description = "Cannot modify job type", //
- content = @Content(schema = @Schema(implementation = ErrorResponse.ErrorInfo.class)))
-
- })
-
+ content = @Content(schema = @Schema(implementation = ErrorResponse.ErrorInfo.class)))})
public Mono<ResponseEntity<Object>> putIndividualEiJob( //
@PathVariable("eiJobId") String eiJobId, //
- @RequestBody A1eEiJobInfo eiJobObject) throws ServiceException {
+ @RequestBody A1eEiJobInfo eiJobObject, //
+ @RequestHeader Map<String, String> headers) throws ServiceException {
final boolean isNewJob = this.infoJobs.get(eiJobId) == null;
InfoType eiType = this.infoTypes.getCompatibleType(eiJobObject.eiTypeId);
- return validatePutEiJob(eiJobId, eiType, eiJobObject) //
+ return authorization.authorizeDataJob(headers, eiType, eiJobObject.jobDefinition, AccessType.WRITE) //
+ .flatMap(x -> validatePutEiJob(eiJobId, eiType, eiJobObject)) //
.flatMap(job -> startEiJob(job, eiType)) //
.doOnNext(newEiJob -> this.infoJobs.put(newEiJob)) //
.map(newEiJob -> new ResponseEntity<>(isNewJob ? HttpStatus.CREATED : HttpStatus.OK)) //
--- /dev/null
+/*-
+ * ========================LICENSE_START=================================
+ * O-RAN-SC
+ * %%
+ * Copyright (C) 2022 Nordix Foundation
+ * %%
+ * 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.
+ * ========================LICENSE_END===================================
+ */
+
+package org.oransc.ics.controllers.authorization;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+import java.lang.invoke.MethodHandles;
+import java.util.Map;
+
+import org.oransc.ics.clients.AsyncRestClient;
+import org.oransc.ics.clients.AsyncRestClientFactory;
+import org.oransc.ics.clients.SecurityContext;
+import org.oransc.ics.configuration.ApplicationConfig;
+import org.oransc.ics.controllers.authorization.SubscriptionAuthRequest.Input.AccessType;
+import org.oransc.ics.exceptions.ServiceException;
+import org.oransc.ics.repository.InfoJob;
+import org.oransc.ics.repository.InfoType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+
+@Component
+public class AuthorizationCheck {
+
+ private final ApplicationConfig applicationConfig;
+ private final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+ private final AsyncRestClient restClient;
+ private static Gson gson = new GsonBuilder().disableHtmlEscaping().create();
+
+ @Autowired
+ public AuthorizationCheck(ApplicationConfig applicationConfig, SecurityContext securityContext) {
+
+ this.applicationConfig = applicationConfig;
+ AsyncRestClientFactory restClientFactory =
+ new AsyncRestClientFactory(applicationConfig.getWebClientConfig(), securityContext);
+ this.restClient = restClientFactory.createRestClientUseHttpProxy("");
+ }
+
+ public Mono<InfoJob> authorizeDataJob(Map<String, String> receivedHttpHeaders, InfoJob job, AccessType accessType) {
+ return authorizeDataJob(receivedHttpHeaders, job.getType(), job.getJobData(), accessType) //
+ .map(x -> job);
+ }
+
+ public Mono<InfoType> authorizeDataJob(Map<String, String> receivedHttpHeaders, InfoType type, Object jobDefinition,
+ AccessType accessType) {
+ if (this.applicationConfig.getAuthAgentUrl().isEmpty()) {
+ return Mono.just(type);
+ }
+
+ String tkn = getAuthToken(receivedHttpHeaders);
+ SubscriptionAuthRequest.Input input = SubscriptionAuthRequest.Input.builder() //
+ .accessType(accessType) //
+ .authToken(tkn) //
+ .infoTypeId(type.getId()) //
+ .jobDefinition(jobDefinition) //
+ .build();
+
+ SubscriptionAuthRequest req = SubscriptionAuthRequest.builder().input(input).build();
+
+ String url = this.applicationConfig.getAuthAgentUrl();
+ return this.restClient.post(url, gson.toJson(req)) //
+ .doOnError(t -> logger.warn("Error returned from auth server: {}", t.getMessage())) //
+ .onErrorResume(t -> Mono.just("")) //
+ .flatMap(this::checkAuthResult) //
+ .map(rsp -> type);
+
+ }
+
+ private String getAuthToken(Map<String, String> httpHeaders) {
+ String tkn = httpHeaders.get("authorization");
+ if (tkn == null) {
+ logger.debug("No authorization token received in {}", httpHeaders);
+ return "";
+ }
+ tkn = tkn.substring("Bearer ".length());
+ return tkn;
+ }
+
+ private Mono<String> checkAuthResult(String response) {
+ logger.debug("Auth result: {}", response);
+ try {
+ AuthorizationResult res = gson.fromJson(response, AuthorizationResult.class);
+ return res != null && res.isResult() ? Mono.just(response)
+ : Mono.error(new ServiceException("Not authorized", HttpStatus.UNAUTHORIZED));
+ } catch (Exception e) {
+ return Mono.error(e);
+ }
+ }
+
+}
--- /dev/null
+/*-
+ * ========================LICENSE_START=================================
+ * O-RAN-SC
+ * %%
+ * Copyright (C) 2022 Nordix Foundation
+ * %%
+ * 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.
+ * ========================LICENSE_END===================================
+ */
+
+package org.oransc.ics.controllers.authorization;
+
+public class AuthorizationConsts {
+
+ public static final String AUTH_API_NAME = "Authorization API";
+ public static final String AUTH_API_DESCRIPTION =
+ "API used for authorization of information job access (this is provided by an authorization producer such as OPA)";
+
+ public static final String GRANT_ACCESS_SUMMARY = "Request for access authorization.";
+ public static final String GRANT_ACCESS_DESCRIPTION = "The authorization function decides if access is granted.";
+
+ private AuthorizationConsts() {
+ }
+
+}
--- /dev/null
+/*-
+ * ========================LICENSE_START=================================
+ * O-RAN-SC
+ * %%
+ * Copyright (C) 2022 Nordix Foundation
+ * %%
+ * 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.
+ * ========================LICENSE_END===================================
+ */
+
+package org.oransc.ics.controllers.authorization;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.gson.annotations.SerializedName;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Getter;
+
+@Schema(name = "authorization_result", description = "Result of authorization")
+@Builder
+public class AuthorizationResult {
+
+ @Schema(name = "result", description = "If true, the access is granted", required = true)
+ @JsonProperty(value = "result", required = true)
+ @SerializedName("result")
+ @Getter
+ private boolean result;
+
+}
--- /dev/null
+/*-
+ * ========================LICENSE_START=================================
+ * O-RAN-SC
+ * %%
+ * Copyright (C) 2022 Nordix Foundation
+ * %%
+ * 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.
+ * ========================LICENSE_END===================================
+ */
+
+package org.oransc.ics.controllers.authorization;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.gson.annotations.SerializedName;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+
+@Schema(name = "subscription_authorization", description = "Authorization request for subscription requests")
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+@ToString
+@Getter
+public class SubscriptionAuthRequest {
+
+ @Schema(name = "input", description = "input")
+ @Builder
+ @AllArgsConstructor
+ @NoArgsConstructor
+ @Getter
+ @ToString
+ public static class Input {
+
+ @Schema(name = "acces_type", description = "Access type")
+ public enum AccessType {
+ READ, WRITE
+ }
+
+ @Schema(name = "access_type", description = "Access type", required = true)
+ @JsonProperty(value = "access_type", required = true)
+ @SerializedName("access_type")
+ private AccessType accessType;
+
+ @Schema(name = "info_type_id", description = "Information type identifier", required = true)
+ @SerializedName("info_type_id")
+ @JsonProperty(value = "info_type_id", required = true)
+ private String infoTypeId;
+
+ @Schema(name = "job_definition", description = "Information type specific job data", required = true)
+ @SerializedName("job_definition")
+ @JsonProperty(value = "job_definition", required = true)
+ private Object jobDefinition;
+
+ @Schema(name = "auth_token", description = "Authorization token", required = true)
+ @SerializedName("auth_token")
+ @JsonProperty(value = "auth_token", required = true)
+ private String authToken;
+
+ }
+
+ @Schema(name = "input", description = "Input", required = true)
+ @JsonProperty(value = "input", required = true)
+ @SerializedName("input")
+ private Input input;
+
+}
@Component
public class ConsumerCallbacks implements InfoTypeSubscriptions.ConsumerCallbackHandler {
- private static Gson gson = new GsonBuilder().create();
+ private static Gson gson = new GsonBuilder().disableHtmlEscaping().create();
private final AsyncRestClient restClient;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
+import java.util.Map;
import org.json.JSONObject;
import org.oransc.ics.controllers.ErrorResponse;
import org.oransc.ics.controllers.VoidResponse;
+import org.oransc.ics.controllers.authorization.AuthorizationCheck;
+import org.oransc.ics.controllers.authorization.SubscriptionAuthRequest.Input.AccessType;
import org.oransc.ics.controllers.r1producer.ProducerCallbacks;
import org.oransc.ics.exceptions.ServiceException;
import org.oransc.ics.repository.InfoJob;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+
+import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@SuppressWarnings("java:S3457") // No need to call "toString()" method as formatting and string ..
private final InfoProducers infoProducers;
private final ProducerCallbacks producerCallbacks;
private final InfoTypeSubscriptions infoTypeSubscriptions;
- private static Gson gson = new GsonBuilder().create();
+ private static Gson gson = new GsonBuilder().disableHtmlEscaping().create();
+ private final AuthorizationCheck authorization;
- public ConsumerController(@Autowired InfoJobs jobs, @Autowired InfoTypes infoTypes,
- @Autowired InfoProducers infoProducers, @Autowired ProducerCallbacks producerCallbacks,
- @Autowired InfoTypeSubscriptions infoTypeSubscriptions) {
+ @Autowired
+ public ConsumerController(InfoJobs jobs, InfoTypes infoTypes, InfoProducers infoProducers,
+ ProducerCallbacks producerCallbacks, InfoTypeSubscriptions infoTypeSubscriptions,
+ AuthorizationCheck authorization) {
this.infoProducers = infoProducers;
this.infoJobs = jobs;
this.infoTypeSubscriptions = infoTypeSubscriptions;
this.infoTypes = infoTypes;
this.producerCallbacks = producerCallbacks;
+ this.authorization = authorization;
}
@GetMapping(path = "/info-types", produces = MediaType.APPLICATION_JSON_VALUE)
value = { //
@ApiResponse(responseCode = "204") //
})
- public ResponseEntity<Object> deleteJobsForOwner( //
-
+ public Mono<ResponseEntity<Object>> deleteJobsForOwner( //
@Parameter(
name = ConsumerConsts.OWNER_PARAM,
required = true, //
description = ConsumerConsts.OWNER_PARAM_DESCRIPTION) //
- @RequestParam(name = ConsumerConsts.OWNER_PARAM, required = true) String owner) {
-
- for (InfoJob job : this.infoJobs.getJobsForOwner(owner)) {
- logger.debug("DELETE info jobs, id: {}, type: {}, owner: {}", job.getId(), job.getType().getId(), owner);
- this.infoJobs.remove(job, this.infoProducers);
- }
- return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ @RequestParam(name = ConsumerConsts.OWNER_PARAM, required = true) String owner, //
+ @RequestHeader Map<String, String> headers) {
+
+ return Flux.fromIterable(this.infoJobs.getJobsForOwner(owner))
+ .doOnNext(job -> logger.debug("DELETE info jobs, id: {}, type: {}, owner: {}", job.getId(),
+ job.getType().getId(), owner))
+ .flatMap(job -> this.authorization.authorizeDataJob(headers, job, AccessType.WRITE)) //
+ .doOnNext(job -> this.infoJobs.remove(job, this.infoProducers)) //
+ .collectList() //
+ .map(l -> new ResponseEntity<>(HttpStatus.NO_CONTENT)) //
+ .onErrorResume(ErrorResponse::createMono);
}
@GetMapping(path = "/info-jobs/{infoJobId}", produces = MediaType.APPLICATION_JSON_VALUE) //
description = "Information subscription job is not found", //
content = @Content(schema = @Schema(implementation = ErrorResponse.ErrorInfo.class))) //
})
- public ResponseEntity<Object> getIndividualInfoJob( //
- @PathVariable(ConsumerConsts.INFO_JOB_ID_PATH) String infoJobId) {
- try {
- logger.debug("GET info job, id: {}", infoJobId);
- InfoJob job = this.infoJobs.getJob(infoJobId);
- return new ResponseEntity<>(gson.toJson(toInfoJobInfo(job)), HttpStatus.OK);
- } catch (Exception e) {
- return ErrorResponse.create(e, HttpStatus.NOT_FOUND);
- }
+ public Mono<ResponseEntity<Object>> getIndividualInfoJob( //
+ @PathVariable(ConsumerConsts.INFO_JOB_ID_PATH) String infoJobId, //
+ @RequestHeader Map<String, String> headers) {
+
+ logger.debug("GET info job, id: {}", infoJobId);
+ return this.infoJobs.getJobMono(infoJobId) //
+ .flatMap(job -> authorization.authorizeDataJob(headers, job, AccessType.READ)) //
+ .map(job -> new ResponseEntity<Object>(gson.toJson(toInfoJobInfo(job)), HttpStatus.OK)) //
+ .onErrorResume(ErrorResponse::createMono);
}
@GetMapping(path = "/info-jobs/{infoJobId}/status", produces = MediaType.APPLICATION_JSON_VALUE)
description = "Information subscription job is not found", //
content = @Content(schema = @Schema(implementation = ErrorResponse.ErrorInfo.class))) //
})
- public ResponseEntity<Object> deleteIndividualInfoJob( //
- @PathVariable(ConsumerConsts.INFO_JOB_ID_PATH) String jobId) {
- try {
- logger.debug("DELETE info job, id: {}", jobId);
- InfoJob job = this.infoJobs.getJob(jobId);
- this.infoJobs.remove(job, this.infoProducers);
- return new ResponseEntity<>(HttpStatus.NO_CONTENT);
- } catch (Exception e) {
- return ErrorResponse.create(e, HttpStatus.NOT_FOUND);
- }
+ public Mono<ResponseEntity<Object>> deleteIndividualInfoJob( //
+ @PathVariable(ConsumerConsts.INFO_JOB_ID_PATH) String jobId, //
+ @RequestHeader Map<String, String> headers) {
+
+ logger.debug("DELETE info job, id: {}", jobId);
+ return this.infoJobs.getJobMono(jobId) //
+ .flatMap(job -> authorization.authorizeDataJob(headers, job, AccessType.WRITE)) //
+ .doOnNext(job -> this.infoJobs.remove(job, this.infoProducers)) //
+ .map(job -> new ResponseEntity<>(HttpStatus.NO_CONTENT)) //
+ .onErrorResume(ErrorResponse::createMono);
}
@PutMapping(
content = @Content(schema = @Schema(implementation = ErrorResponse.ErrorInfo.class)))})
public Mono<ResponseEntity<Object>> putIndividualInfoJob( //
@PathVariable(ConsumerConsts.INFO_JOB_ID_PATH) String jobId, //
- @RequestBody ConsumerJobInfo informationJobObject) throws ServiceException {
+ @RequestBody ConsumerJobInfo informationJobObject, //
+ @RequestHeader Map<String, String> headers) throws ServiceException {
final boolean isNewJob = this.infoJobs.get(jobId) == null;
logger.debug("PUT info job, id: {}, obj: {}", jobId, informationJobObject);
InfoType infoType = this.infoTypes.getCompatibleType(informationJobObject.infoTypeId);
- return validatePutInfoJob(jobId, infoType, informationJobObject) //
+ return authorization.authorizeDataJob(headers, infoType, informationJobObject.jobDefinition, AccessType.WRITE) //
+ .flatMap(x -> validatePutInfoJob(jobId, infoType, informationJobObject)) //
.flatMap(job -> startInfoSubscriptionJob(job, infoType)) //
.doOnNext(this.infoJobs::put) //
.map(newJob -> new ResponseEntity<>(isNewJob ? HttpStatus.CREATED : HttpStatus.OK)) //
- .onErrorResume(throwable -> Mono.just(ErrorResponse.create(throwable, HttpStatus.NOT_FOUND)));
+ .onErrorResume(ErrorResponse::createMono);
}
@GetMapping(path = "/info-type-subscription", produces = MediaType.APPLICATION_JSON_VALUE)
content = @Content(schema = @Schema(implementation = ErrorResponse.ErrorInfo.class))) //
})
public ResponseEntity<Object> getIndividualTypeSubscription( //
- @PathVariable(ConsumerConsts.SUBSCRIPTION_ID_PATH) String subscriptionId) {
+ @PathVariable(ConsumerConsts.SUBSCRIPTION_ID_PATH) String subscriptionId, //
+ @RequestHeader Map<String, String> headers) {
try {
logger.debug("GET info type subscription, subscriptionId: {}", subscriptionId);
InfoTypeSubscriptions.SubscriptionInfo subscription =
public class ProducerCallbacks {
private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
- private static Gson gson = new GsonBuilder().create();
+ private static Gson gson = new GsonBuilder().disableHtmlEscaping().create();
private final AsyncRestClient restClient;
@Tag(name = ProducerConsts.PRODUCER_API_NAME, description = ProducerConsts.PRODUCER_API_DESCRIPTION)
public class ProducerController {
- private static Gson gson = new GsonBuilder().create();
+ private static Gson gson = new GsonBuilder().disableHtmlEscaping().create();
private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
@Autowired
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
+
import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
/**
* Dynamic representation of all existing Information Jobs.
return new Vector<>(allEiJobs.values());
}
+ public synchronized Mono<InfoJob> getJobMono(String id) {
+ InfoJob job = allEiJobs.get(id);
+ if (job == null) {
+ return Mono.error(new ServiceException("Could not find Information job: " + id, HttpStatus.NOT_FOUND));
+ }
+ return Mono.just(job);
+ }
+
public synchronized InfoJob getJob(String id) throws ServiceException {
InfoJob ric = allEiJobs.get(id);
if (ric == null) {
private final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private final Map<String, SubscriptionInfo> allSubscriptions = new HashMap<>();
private final MultiMap<String, SubscriptionInfo> subscriptionsByOwner = new MultiMap<>();
- private final Gson gson = new GsonBuilder().create();
+ private final Gson gson = new GsonBuilder().disableHtmlEscaping().create();
private final Map<String, ConsumerCallbackHandler> callbackHandlers = new HashMap<>();
private final DataStore dataStore;
import org.oransc.ics.configuration.WebClientConfig.HttpProxyConfig;
import org.oransc.ics.controller.A1eCallbacksSimulatorController;
import org.oransc.ics.controller.ConsumerSimulatorController;
+import org.oransc.ics.controller.OpenPolicyAgentSimulatorController;
import org.oransc.ics.controller.ProducerSimulatorController;
import org.oransc.ics.controllers.a1e.A1eConsts;
import org.oransc.ics.controllers.a1e.A1eEiJobInfo;
import org.oransc.ics.controllers.a1e.A1eEiJobStatus;
import org.oransc.ics.controllers.a1e.A1eEiTypeInfo;
+import org.oransc.ics.controllers.authorization.SubscriptionAuthRequest;
+import org.oransc.ics.controllers.authorization.SubscriptionAuthRequest.Input.AccessType;
import org.oransc.ics.controllers.r1consumer.ConsumerConsts;
import org.oransc.ics.controllers.r1consumer.ConsumerInfoTypeInfo;
import org.oransc.ics.controllers.r1consumer.ConsumerJobInfo;
@Autowired
SecurityContext securityContext;
- private static Gson gson = new GsonBuilder().create();
+ @Autowired
+ OpenPolicyAgentSimulatorController openPolicyAgentSimulatorController;
+
+ private static Gson gson = new GsonBuilder().disableHtmlEscaping().create();
/**
* Overrides the BeanFactory.
this.consumerSimulator.getTestResults().reset();
this.a1eCallbacksSimulator.getTestResults().reset();
this.securityContext.setAuthTokenFilePath(null);
+ this.applicationConfig.setAuthAgentUrl("");
+ this.openPolicyAgentSimulatorController.getTestResults().reset();
}
@AfterEach
putInfoProducerWithOneType("producer1", TYPE_ID);
putInfoJob(TYPE_ID, "jobId1");
putInfoJob(TYPE_ID, "jobId2");
- putInfoJob(TYPE_ID, "jobId3", "otherOwner");
+ putEiJob(TYPE_ID, "jobId3", "otherOwner");
assertThat(this.infoJobs.size()).isEqualTo(3);
String url = ConsumerConsts.API_ROOT + "/info-jobs?owner=owner";
restClient().delete(url).block();
}
@Test
- void consumerDeleteEiJob() throws Exception {
+ void consumerDeleteInfoJob() throws Exception {
putInfoProducerWithOneType(PRODUCER_ID, TYPE_ID);
putInfoJob(TYPE_ID, "jobId");
assertThat(this.infoJobs.size()).isEqualTo(1);
putInfoProducerWithOneType(REG_TYPE_ID5, REG_TYPE_ID5);
String url = ConsumerConsts.API_ROOT + "/info-jobs/jobId";
- String body = gson.toJson(consumerJobInfo(PUT_TYPE_ID, "jobId"));
+ String body = gson.toJson(consumerJobInfo(PUT_TYPE_ID, "jobId", "owner"));
ResponseEntity<String> resp = restClient().putForEntity(url, body).block();
assertThat(this.infoJobs.size()).isEqualTo(1);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
putInfoJob("typeId1", "jobId");
String url = ConsumerConsts.API_ROOT + "/info-jobs/jobId";
- String body = gson.toJson(consumerJobInfo("typeId2", "jobId"));
+ String body = gson.toJson(consumerJobInfo("typeId2", "jobId", "owner"));
testErrorCode(restClient().put(url, body), HttpStatus.CONFLICT, "Not allowed to change type for existing job");
}
"Could not find Information subscription: junk");
}
+ @Test
+ void testAuthorization() throws Exception {
+ this.applicationConfig.setAuthAgentUrl(baseUrl() + OpenPolicyAgentSimulatorController.SUBSCRIPTION_AUTH_URL);
+ final String AUTH_TOKEN = "testToken";
+ Path authFile = Files.createTempFile("icsTestAuthToken", ".txt");
+ Files.write(authFile, AUTH_TOKEN.getBytes());
+ this.securityContext.setAuthTokenFilePath(authFile);
+ putInfoProducerWithOneType(PRODUCER_ID, TYPE_ID);
+ putInfoJob(TYPE_ID, "jobId");
+
+ var testResults = openPolicyAgentSimulatorController.getTestResults();
+
+ await().untilAsserted(() -> assertThat(testResults.receivedRequests).hasSize(1));
+
+ // Test OPA check
+ SubscriptionAuthRequest authRequest = testResults.receivedRequests.get(0);
+ assertThat(authRequest.getInput().getAccessType()).isEqualTo(AccessType.WRITE);
+ assertThat(authRequest.getInput().getInfoTypeId()).isEqualTo(TYPE_ID);
+ assertThat(authRequest.getInput().getAuthToken()).isEqualTo(AUTH_TOKEN);
+
+ // Test rejection from OPA
+ this.applicationConfig
+ .setAuthAgentUrl(baseUrl() + OpenPolicyAgentSimulatorController.SUBSCRIPTION_REJECT_AUTH_URL);
+
+ String url = ConsumerConsts.API_ROOT + "/info-jobs/jobId";
+ testErrorCode(restClient().delete(url), HttpStatus.UNAUTHORIZED, "Not authorized");
+ assertThat(testResults.receivedRequests).hasSize(2);
+ authRequest = testResults.receivedRequests.get(1);
+ assertThat(authRequest.getInput().getAccessType()).isEqualTo(AccessType.WRITE);
+ }
+
@Test
void testAuthHeader() throws Exception {
final String AUTH_TOKEN = "testToken";
Files.delete(authFile);
- // Test that it works. The cached header is used
+ // Test that it works when the file is deleted. The cached header is used
putInfoJob(TYPE_ID, "jobId2");
await().untilAsserted(() -> assertThat(this.infoJobs.size()).isEqualByComparingTo(2));
headers = this.producerSimulator.getTestResults().receivedHeaders.get(1);
}
private ConsumerJobInfo consumerJobInfo() throws JsonMappingException, JsonProcessingException {
- return consumerJobInfo(TYPE_ID, EI_JOB_ID);
+ return consumerJobInfo(TYPE_ID, EI_JOB_ID, "owner");
}
- ConsumerJobInfo consumerJobInfo(String typeId, String infoJobId)
+ ConsumerJobInfo consumerJobInfo(String typeId, String infoJobId, String owner)
throws JsonMappingException, JsonProcessingException {
- return new ConsumerJobInfo(typeId, jsonObject(), "owner", "https://junk.com",
+ return new ConsumerJobInfo(typeId, jsonObject(), owner, "https://junk.com",
baseUrl() + A1eCallbacksSimulatorController.getJobStatusUrl(infoJobId));
}
return jsonObject("{ " + EI_JOB_PROPERTY + " : \"value\" }");
}
- private InfoJob putInfoJob(String infoTypeId, String jobId, String owner) throws Exception {
+ private InfoJob putEiJob(String infoTypeId, String jobId, String owner) throws Exception {
String url = A1eConsts.API_ROOT + "/eijobs/" + jobId;
String body = gson.toJson(eiJobInfo(infoTypeId, jobId, owner));
restClient().putForEntity(url, body).block();
-
return this.infoJobs.getJob(jobId);
}
private InfoJob putInfoJob(String infoTypeId, String jobId) throws Exception {
+ return putInfoJob(infoTypeId, jobId, "owner");
+ }
- String url = A1eConsts.API_ROOT + "/eijobs/" + jobId;
- String body = gson.toJson(eiJobInfo(infoTypeId, jobId));
+ private InfoJob putInfoJob(String infoTypeId, String jobId, String owner) throws Exception {
+ String url = ConsumerConsts.API_ROOT + "/info-jobs/" + jobId;
+ String body = gson.toJson(consumerJobInfo(infoTypeId, jobId, owner));
restClient().putForEntity(url, body).block();
return this.infoJobs.getJob(jobId);
--- /dev/null
+/*-
+ * ========================LICENSE_START=================================
+ * O-RAN-SC
+ * %%
+ * Copyright (C) 2022 Nordix Foundation
+ * %%
+ * 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.
+ * ========================LICENSE_END===================================
+ */
+
+package org.oransc.ics.controller;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+import java.lang.invoke.MethodHandles;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import lombok.Getter;
+
+import org.oransc.ics.controllers.VoidResponse;
+import org.oransc.ics.controllers.authorization.AuthorizationConsts;
+import org.oransc.ics.controllers.authorization.AuthorizationResult;
+import org.oransc.ics.controllers.authorization.SubscriptionAuthRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController("OpenPolicyAgentSimulatorController")
+@Tag(name = AuthorizationConsts.AUTH_API_NAME, description = AuthorizationConsts.AUTH_API_DESCRIPTION)
+public class OpenPolicyAgentSimulatorController {
+ private static Gson gson = new GsonBuilder().disableHtmlEscaping().create();
+
+ private final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+ public static final String SUBSCRIPTION_AUTH_URL = "/example-subscription-auth";
+ public static final String SUBSCRIPTION_REJECT_AUTH_URL = "/example-subscription-auth-reject";
+
+ public static class TestResults {
+
+ public List<SubscriptionAuthRequest> receivedRequests =
+ Collections.synchronizedList(new ArrayList<SubscriptionAuthRequest>());
+
+ public TestResults() {
+ }
+
+ public void reset() {
+ receivedRequests.clear();
+
+ }
+ }
+
+ @Getter
+ private TestResults testResults = new TestResults();
+
+ @PostMapping(path = SUBSCRIPTION_AUTH_URL, produces = MediaType.APPLICATION_JSON_VALUE)
+ @Operation(
+ summary = AuthorizationConsts.GRANT_ACCESS_SUMMARY,
+ description = AuthorizationConsts.GRANT_ACCESS_DESCRIPTION)
+ @ApiResponses(
+ value = { //
+ @ApiResponse(
+ responseCode = "200",
+ description = "OK", //
+ content = @Content(schema = @Schema(implementation = AuthorizationResult.class))) //
+ })
+ public ResponseEntity<Object> subscriptionAuth( //
+ @RequestHeader Map<String, String> headers, //
+ @RequestBody SubscriptionAuthRequest request) {
+ logger.info("Auth {}", request);
+ testResults.receivedRequests.add(request);
+
+ String res = gson.toJson(AuthorizationResult.builder().result(true).build());
+ return new ResponseEntity<>(res, HttpStatus.OK);
+ }
+
+ @PostMapping(path = SUBSCRIPTION_REJECT_AUTH_URL, produces = MediaType.APPLICATION_JSON_VALUE)
+ @Operation(summary = "Rejecting", description = "", hidden = true)
+ @ApiResponses(
+ value = { //
+ @ApiResponse(
+ responseCode = "200",
+ description = "OK", //
+ content = @Content(schema = @Schema(implementation = VoidResponse.class))) //
+ })
+ public ResponseEntity<Object> subscriptionAuthReject( //
+ @RequestHeader Map<String, String> headers, //
+ @RequestBody SubscriptionAuthRequest request) {
+ logger.info("Auth Reject {}", request);
+ testResults.receivedRequests.add(request);
+ String res = gson.toJson(AuthorizationResult.builder().result(false).build());
+ return new ResponseEntity<>(res, HttpStatus.OK);
+ }
+
+}
content = @Content(schema = @Schema(implementation = VoidResponse.class))) //
})
public ResponseEntity<Object> jobDeletedCallback( //
- @RequestHeader Map<String, String> headers, @PathVariable(ConsumerConsts.INFO_JOB_ID_PATH) String infoJobId) {
+ @RequestHeader Map<String, String> headers, //
+ @PathVariable(ConsumerConsts.INFO_JOB_ID_PATH) String infoJobId) {
try {
logHeaders(headers);
logger.info("Job deleted callback {}", infoJobId);