diff --git a/.gitignore b/.gitignore index ed94d71d..5f43da8a 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ integration-tests/.built __generated__ __pycache__ __debug_bin +management/cmd/management-service/management-service # Test binary, built with `go test -c` *.test diff --git a/Makefile b/Makefile index fdd58d8e..4c3ffd5b 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ SUBDIR += config SUBDIR += handler SUBDIR += kvstore SUBDIR += log +SUBDIR += management SUBDIR += plugin SUBDIR += policy SUBDIR += proto diff --git a/deployments/docker/Makefile b/deployments/docker/Makefile index c551ca5d..3ca4a547 100644 --- a/deployments/docker/Makefile +++ b/deployments/docker/Makefile @@ -24,6 +24,7 @@ SRC_DIR := $(THIS_DIR)src/ BUILDER_CONTEXT := $(CONTEXT_DIR)/builder vts_FLAGS := -v $(STORES_VOLUME):/opt/veraison/stores +management_FLAGS := -v $(STORES_VOLUME):/opt/veraison/stores -p $(MANAGEMENT_PORT):$(MANAGEMENT_PORT) provisioning_FLAGS := -p $(PROVISIONING_PORT):$(PROVISIONING_PORT) verification_FLAGS := -p $(VERIFICATION_PORT):$(VERIFICATION_PORT) @@ -85,7 +86,8 @@ services: @# image targets (possibly because of the need to recursively resolve %, @# but I haven't looked too much into it). Recursively calling $(MAKE) here @# resolves the issue. - $(MAKE) .built/vts-container .built/provisioning-container .built/verification-container + $(MAKE) .built/vts-container .built/provisioning-container .built/verification-container \ + .built/management-container .PHONY: vts vts: deploy .built/vts-container @@ -105,6 +107,12 @@ verification: deploy .built/verification-container .PHONY: verification-image verification-image: deploy .built/verification-image +.PHONY: management +management: deploy .built/management-container + +.PHONY: management-image +management-image: deploy .built/management-image + .PHONY: network network: .built/network @@ -165,7 +173,7 @@ docker-clean: docker volume rm -f $(DEPLOY_DEST); \ fi @# -f ensures exit code 0, even if image doesn't exist - docker container rm -f vts-service provisioning-service verification-service + docker container rm -f vts-service provisioning-service verification-service management-service docker volume rm -f veraison-logs veraison-stores @# ubuntu uses an older version of docker without -f option for network; hence the || : cludge docker network rm $(VERAISON_NETWORK) || : diff --git a/deployments/docker/deployment.cfg b/deployments/docker/deployment.cfg index c4bb30bf..3bdd2077 100644 --- a/deployments/docker/deployment.cfg +++ b/deployments/docker/deployment.cfg @@ -12,6 +12,7 @@ VERAISON_NETWORK=veraison-net VTS_PORT=50051 PROVISIONING_PORT=8888 VERIFICATION_PORT=8080 +MANAGEMENT_PORT=8088 # Deploy destination is either an absolute path to a directory on the host, or # the name of a docker volume. diff --git a/deployments/docker/src/builder-dispatcher b/deployments/docker/src/builder-dispatcher index 3de53243..cad32bdc 100755 --- a/deployments/docker/src/builder-dispatcher +++ b/deployments/docker/src/builder-dispatcher @@ -31,7 +31,7 @@ function deploy() { cp $BUILD_DIR/provisioning/cmd/provisioning-service/provisioning-service $DEPLOY_DIR/ cp $BUILD_DIR/verification/cmd/verification-service/verification-service $DEPLOY_DIR/ cp $BUILD_DIR/vts/cmd/vts-service/vts-service $DEPLOY_DIR/ - cp $BUILD_DIR/vts/cmd/vts-service/vts-service $DEPLOY_DIR/ + cp $BUILD_DIR/management/cmd/management-service/management-service $DEPLOY_DIR/ cp $BUILD_DIR/scheme/bin/* $DEPLOY_DIR/plugins/ cp $BUILD_DIR/deployments/docker/src/skey.jwk $DEPLOY_DIR/ cp $BUILD_DIR/deployments/docker/src/service-entrypoint $DEPLOY_DIR/ diff --git a/deployments/docker/src/config.yaml.template b/deployments/docker/src/config.yaml.template index 6f1edce9..717ab91d 100644 --- a/deployments/docker/src/config.yaml.template +++ b/deployments/docker/src/config.yaml.template @@ -7,6 +7,8 @@ provisioning: listen-addr: 0.0.0.0:${PROVISIONING_PORT} verification: listen-addr: 0.0.0.0:${VERIFICATION_PORT} +management: + listen-addr: 0.0.0.0:${MANAGEMENT_PORT} vts: server-addr: vts-service:${VTS_PORT} ear-signer: diff --git a/deployments/docker/src/load-config.mk b/deployments/docker/src/load-config.mk index c9e266e8..d074c7e8 100644 --- a/deployments/docker/src/load-config.mk +++ b/deployments/docker/src/load-config.mk @@ -10,6 +10,7 @@ VERAISON_NETWORK ?= veraison-net VTS_PORT ?= 50051 PROVISIONING_PORT ?= 8888 VERIFICATION_PORT ?= 8080 +MANAGEMENT_PORT ?= 8088 # Deploy destination is either an absolute path to a directory on the host, or # the name of a docker volume. diff --git a/deployments/docker/src/management.docker b/deployments/docker/src/management.docker new file mode 100644 index 00000000..c8c7513f --- /dev/null +++ b/deployments/docker/src/management.docker @@ -0,0 +1,28 @@ +# Management service container. +# The context for building this image is assumed to be the Veraison deployment +# directory (/tmp/veraison is the default for make build). +FROM debian as veraison-management + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install \ + --assume-yes \ + --no-install-recommends \ + uuid-runtime \ + && uuidgen | tr -d - > /etc/machine-id \ + && apt-get clean \ + && apt-get autoremove --assume-yes \ + && rm -rf /var/lib/apt/lists/* /var/tmp/* /tmp/* + +RUN groupadd -g 616 veraison && \ + useradd -m -g veraison --system veraison + +USER veraison + +WORKDIR /opt/veraison + +ADD --chown=veraison:nogroup plugins plugins +ADD --chown=veraison:nogroup config.yaml management-service service-entrypoint ./ + +ENTRYPOINT ["/opt/veraison/service-entrypoint"] +CMD ["/opt/veraison/management-service"] + diff --git a/deployments/docker/veraison b/deployments/docker/veraison index 4a92c974..374dc60a 100755 --- a/deployments/docker/veraison +++ b/deployments/docker/veraison @@ -8,19 +8,21 @@ function status() { local vts=$(_get_container_state vts-service) local prov=$(_get_container_state provisioning-service) local verif=$(_get_container_state verification-service) + local manage=$(_get_container_state management-service) if [[ $_quiet == true ]]; then local vts=$(_strip_color $vts) local prov=$(_strip_color $prov) local verif=$(_strip_color $verif) + local manage=$(_strip_color $manage) local status="${_yell}stopped${_reset}" - if [[ "$vts" == "running" || "$prov" == "running" || "$verif" == "running" ]]; then + if [[ "$vts" == "running" || "$prov" == "running" || "$verif" == "running" || "$manage" == "running" ]]; then status="${_yell}partial${_yell}" fi - if [[ "$vts" == "running" && "$prov" == "running" && "$verif" == "running" ]]; then + if [[ "$vts" == "running" && "$prov" == "running" && "$verif" == "running" && "$manage" == "running" ]]; then status="${_green}running${_reset}" fi @@ -29,6 +31,7 @@ function status() { echo -e " vts: $vts" echo -e "provisioning: $prov" echo -e "verification: $verif" + echo -e " management: $manage" fi } @@ -40,12 +43,15 @@ function start() { sleep 0.5 # wait for vts to start before starting the services that depend on it. start_provisioning start_verification + start_management elif [[ "$what" == "vts" || "$what" == "vts-service" ]]; then start_vts elif [[ "$what" == "provisioning" || "$what" == "provisioning-service" ]]; then start_provisioning elif [[ "$what" == "verification" || "$what" == "verification-service" ]]; then start_verification + elif [[ "$what" == "management" || "$what" == "management-service" ]]; then + start_management else echo -e "$_error: unknown service: $what" exit 1 @@ -56,6 +62,7 @@ function stop() { local what=$1 if [[ "x$what" == "x" ]]; then + stop_management stop_verification stop_provisioning stop_vts @@ -65,6 +72,8 @@ function stop() { stop_provisioning elif [[ "$what" == "verification" || "$what" == "verification-service" ]]; then stop_verification + elif [[ "$what" == "management" || "$what" == "management-service" ]]; then + stop_management else echo -e "$_error: unknown service: $what" exit 1 @@ -80,6 +89,8 @@ function follow() { follow_provisioning elif [[ "$what" == "verification" || "$what" == "verification-service" ]]; then follow_verification + elif [[ "$what" == "management" || "$what" == "management-service" ]]; then + follow_management else echo -e "$_error: unknown service: $what" exit 1 @@ -122,6 +133,18 @@ function follow_verification() { docker container logs --follow --timestamps verification-service } +function start_management() { + docker container start management-service +} + +function stop_management() { + docker container stop management-service +} + +function follow_management() { + docker container logs --follow --timestamps management-service +} + function manager() { docker container run --rm -t \ --network veraison-net \ diff --git a/integration-tests/Makefile b/integration-tests/Makefile index 7a45d5ad..c9668391 100644 --- a/integration-tests/Makefile +++ b/integration-tests/Makefile @@ -26,11 +26,6 @@ CONTAINER_FLAGS := --env-file $(DEPLOYMENT_SRC_DIR)deployment.cfg --network ver -v $(THIS_DIR):/integration-tests \ -v $(STORES_VOLUME):/opt/veraison/stores -DEPLOYMENT_DEPS := $(DEPLOYMENT_SRC_DIR).built/network \ - $(DEPLOYMENT_SRC_DIR).built/vts-container \ - $(DEPLOYMENT_SRC_DIR).built/provisioning-container \ - $(DEPLOYMENT_SRC_DIR).built/verification-container - CLEANFILES := .pytest_cache utils/__pycache__ __generated__ .PHONY: image diff --git a/integration-tests/data/policies/psa-short.rego b/integration-tests/data/policies/psa-short.rego new file mode 100644 index 00000000..e2eaab93 --- /dev/null +++ b/integration-tests/data/policies/psa-short.rego @@ -0,0 +1,3 @@ +package policy + +executables = APPROVED_RT diff --git a/integration-tests/data/policies/psa.rego b/integration-tests/data/policies/psa.rego new file mode 100644 index 00000000..a4e2e9a4 --- /dev/null +++ b/integration-tests/data/policies/psa.rego @@ -0,0 +1,9 @@ +package policy + +executables = APPROVED_RT { + some i + + evidence["psa-software-components"][i]["measurement-type"] == "BL" + + semver_cmp(evidence["psa-software-components"][i].version, "3.5") >= 0 +} else = UNSAFE_RT diff --git a/integration-tests/docker/bashrc b/integration-tests/docker/bashrc index bf73cd4f..34e3d3c1 100644 --- a/integration-tests/docker/bashrc +++ b/integration-tests/docker/bashrc @@ -2,6 +2,7 @@ export PATH=~/.local/bin:$PATH export PYTHONPATH=$PYTHONPATH:/integration-testing/utils export PROVISIONING_HOST=provisioning-service export VERIFICATION_HOST=verification-service +export MANAGEMENT_HOST=management-service export PS1='\e[0;32m\u@debug-container \e[0;34m\w\n\e[0;32m$\e[0m ' alias ll='ls -lh --color=auto' diff --git a/integration-tests/tests/common.yaml b/integration-tests/tests/common.yaml index 200014ee..067e8c96 100644 --- a/integration-tests/tests/common.yaml +++ b/integration-tests/tests/common.yaml @@ -4,6 +4,7 @@ description: Common test information variables: provisioning-service: '{tavern.env_vars.PROVISIONING_HOST}.{tavern.env_vars.VERAISON_NETWORK}:{tavern.env_vars.PROVISIONING_PORT}' verification-service: '{tavern.env_vars.VERIFICATION_HOST}.{tavern.env_vars.VERAISON_NETWORK}:{tavern.env_vars.VERIFICATION_PORT}' + management-service: '{tavern.env_vars.MANAGEMENT_HOST}.{tavern.env_vars.VERAISON_NETWORK}:{tavern.env_vars.MANAGEMENT_PORT}' good-nonce: QUp8F0FBs9DpodKK8xUg8NQimf6sQAfe2J1ormzZLxk= bad-nonce: Ppfdfe2JzZLOk= endorsements-content-types: diff --git a/integration-tests/tests/test_policy_management.tavern.yaml b/integration-tests/tests/test_policy_management.tavern.yaml new file mode 100644 index 00000000..16b388aa --- /dev/null +++ b/integration-tests/tests/test_policy_management.tavern.yaml @@ -0,0 +1,160 @@ +test_name: policy-management + +includes: + - !include common.yaml + +stages: + - name: get active policy (non-existent) + request: + method: GET + url: http://{management-service}/management/v1/policy/PSA_IOT + headers: + accept: application/vnd.veraison.policy+json + response: + status_code: 404 + + - name: submit initial policy + request: + method: POST + url: http://{management-service}/management/v1/policy/PSA_IOT + headers: + content-type: application/vnd.veraison.policy.opa + accept: application/vnd.veraison.policy+json + file_body: data/policies/psa-short.rego + response: + status_code: 201 + save: + json: + policy-uuid: uuid + verify_response_with: + - function: checkers:check_policy + extra_kwargs: + active: false + name: default + rules_file: data/policies/psa-short.rego + + - name: get active policy (none activated) + request: + method: GET + url: http://{management-service}/management/v1/policy/PSA_IOT + headers: + accept: application/vnd.veraison.policy+json + response: + status_code: 404 + + - name: get policy by uuid + request: + method: GET + url: http://{management-service}/management/v1/policy/PSA_IOT/{policy-uuid} + headers: + accept: application/vnd.veraison.policy+json + response: + status_code: 200 + verify_response_with: + - function: checkers:check_policy + extra_kwargs: + active: false + name: default + rules_file: data/policies/psa-short.rego + + - name: activate policy + request: + method: POST + url: http://{management-service}/management/v1/policy/PSA_IOT/{policy-uuid}/activate + response: + status_code: 200 + + - name: get active policy (ok) + request: + method: GET + url: http://{management-service}/management/v1/policy/PSA_IOT + headers: + accept: application/vnd.veraison.policy+json + response: + status_code: 200 + verify_response_with: + - function: checkers:check_policy + extra_kwargs: + active: true + name: default + rules_file: data/policies/psa-short.rego + + - name: get active policy (bad scheme) + request: + method: GET + url: http://{management-service}/management/v1/policy/BAD + headers: + accept: application/vnd.veraison.policy+json + response: + status_code: 400 + json: + title: Bad Request + detail: unrecognised scheme "BAD" + + - name: submit replacement policy + request: + method: POST + url: http://{management-service}/management/v1/policy/PSA_IOT?name=test + headers: + content-type: application/vnd.veraison.policy.opa + accept: application/vnd.veraison.policy+json + file_body: data/policies/psa.rego + response: + status_code: 201 + save: + json: + second-policy-uuid: uuid + verify_response_with: + - function: checkers:check_policy + extra_kwargs: + active: false + name: test + rules_file: data/policies/psa.rego + + - name: get active policy (ok) + request: + method: GET + url: http://{management-service}/management/v1/policy/PSA_IOT + headers: + accept: application/vnd.veraison.policy+json + response: + status_code: 200 + verify_response_with: + - function: checkers:check_policy + extra_kwargs: + active: true + name: default + rules_file: data/policies/psa-short.rego + + - name: get policies (one active) + request: + method: GET + url: http://{management-service}/management/v1/policies/PSA_IOT + headers: + accept: application/vnd.veraison.policies+json + response: + status_code: 200 + verify_response_with: + - function: checkers:check_policy_list + extra_kwargs: + have_active: true + + - name: deactivate all + request: + method: POST + url: http://{management-service}/management/v1/policies/PSA_IOT/deactivate + response: + status_code: 200 + + - name: get policies (no active) + request: + method: GET + url: http://{management-service}/management/v1/policies/PSA_IOT + headers: + accept: application/vnd.veraison.policies+json + response: + status_code: 200 + verify_response_with: + - function: checkers:check_policy_list + extra_kwargs: + have_active: false diff --git a/integration-tests/utils/checkers.py b/integration-tests/utils/checkers.py index 3adca72a..8a825073 100644 --- a/integration-tests/utils/checkers.py +++ b/integration-tests/utils/checkers.py @@ -1,10 +1,10 @@ import os import json +from datetime import datetime, timedelta from jose import jwt GENDIR = '__generated__' - def save_result(response, scheme, evidence): os.makedirs(f'{GENDIR}/results', exist_ok=True) jwt_outfile = f'{GENDIR}/results/{scheme}.{evidence}.jwt' @@ -49,6 +49,43 @@ def compare_to_expected_result(response, expected, verifier_key): expected_claims["ear.veraison.policy-claims"] +def check_policy(response, active, name, rules_file): + policy = _extract_policy(response.json()) + + _check_within_period(policy['ctime'], timedelta(seconds=60)) + + if active is not None: + assert policy['active'] == active + + if name: + assert policy['name'] == name + + assert policy['type'] == 'opa' + + if rules_file: + with open(rules_file) as fh: + rules = fh.read() + + assert policy['rules'] == rules + + +def check_policy_list(response, have_active): + active_count = 0 + for entry in response.json(): + policy = _extract_policy(entry) + _check_within_period(policy['ctime'], timedelta(seconds=60)) + if policy['active']: + active_count += 1 + + assert (have_active and active_count == 1) or \ + (not have_active and active_count == 0) + + +def _check_within_period(dt, period): + now = datetime.now().replace(tzinfo=dt.tzinfo) + assert now > dt > (now - period) + + def _extract_appraisal(response, key_file): try: result = response.json()["result"] @@ -67,3 +104,7 @@ def _extract_appraisal(response, key_file): return decoded["submods"].popitem()[1] +def _extract_policy(data): + policy = data + policy['ctime'] = datetime.fromisoformat(policy['ctime']) + return policy diff --git a/management/Makefile b/management/Makefile new file mode 100644 index 00000000..e385428d --- /dev/null +++ b/management/Makefile @@ -0,0 +1,7 @@ +# Copyright 2021-2022 Contributors to the Veraison project. +# SPDX-License-Identifier: Apache-2.0 + +SUBDIR := api +SUBDIR += cmd/management-service + +include ../mk/subdir.mk diff --git a/management/api/Makefile b/management/api/Makefile new file mode 100644 index 00000000..c28b05fd --- /dev/null +++ b/management/api/Makefile @@ -0,0 +1,9 @@ +# Copyright 2022 Contributors to the Veraison project. +# SPDX-License-Identifier: Apache-2.0 + +.DEFAULT_GOAL := test + +include ../../mk/common.mk +include ../../mk/pkg.mk +include ../../mk/lint.mk +include ../../mk/test.mk diff --git a/management/api/handler.go b/management/api/handler.go new file mode 100644 index 00000000..61d7e748 --- /dev/null +++ b/management/api/handler.go @@ -0,0 +1,307 @@ +// Copyright 2023 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/moogar0880/problems" + "github.com/veraison/services/capability" + "github.com/veraison/services/config" + "github.com/veraison/services/log" + "github.com/veraison/services/management" + "github.com/veraison/services/policy" + "go.uber.org/zap" +) + +const ( + RulesMediaType = "application/vnd.veraison.policy.opa" + PolicyMediaType = "application/vnd.veraison.policy+json" + PoliciesMediaType = "application/vnd.veraison.policies+json" +) + +var ( + tenantID = "0" +) + +type Handler struct { + Manager *management.PolicyManager + Logger *zap.SugaredLogger +} + +func NewHandler(manager *management.PolicyManager, logger *zap.SugaredLogger) Handler { + return Handler{ + Manager: manager, + Logger: logger, + } +} + +func (o Handler) CreatePolicy(c *gin.Context) { + offered := c.NegotiateFormat(PolicyMediaType) + if offered != PolicyMediaType { + reportProblem(c, + http.StatusNotAcceptable, + fmt.Sprintf("the only supported output format is %s", + PolicyMediaType), + ) + return + } + + mediaType := c.Request.Header.Get("Content-Type") + if mediaType != RulesMediaType { + reportProblem(c, + http.StatusBadRequest, + fmt.Sprintf("the only supported rules format is %s", + RulesMediaType), + ) + return + } + + scheme := c.Param("scheme") + if !o.Manager.IsSchemeSupported(scheme) { + reportProblem(c, + http.StatusBadRequest, + fmt.Sprintf("unrecognised scheme %q", scheme), + ) + return + } + + name := c.Query("name") + if name == "" { + name = "default" + } + + payload, err := io.ReadAll(c.Request.Body) + if err != nil { + reportProblem(c, http.StatusBadRequest, fmt.Sprintf("error reading body: %s", err)) + return + } + + if len(payload) == 0 { + reportProblem(c, http.StatusBadRequest, "empty body") + return + } + + policyRules := string(payload) + + if err = o.Manager.Validate(c, policyRules); err != nil { + reportProblem(c, http.StatusBadRequest, fmt.Sprintf("invalid policy: %s", err)) + } + + policy, err := o.Manager.Update(c, tenantID, scheme, name, policyRules) + if err != nil { + reportProblem(c, + http.StatusInternalServerError, + fmt.Sprintf("could not update policy: %s", err), + ) + } + + respBytes, err := json.Marshal(&policy) + if err != nil { + reportProblem(c, http.StatusInternalServerError, err.Error()) + } + + c.Data(http.StatusCreated, PolicyMediaType, respBytes) +} + +func (o Handler) GetActivePolicy(c *gin.Context) { + offered := c.NegotiateFormat(PolicyMediaType) + if offered != PolicyMediaType { + reportProblem(c, + http.StatusNotAcceptable, + fmt.Sprintf("the only supported output format is %s", + PolicyMediaType), + ) + return + } + + scheme := c.Param("scheme") + if !o.Manager.IsSchemeSupported(scheme) { + reportProblem(c, + http.StatusBadRequest, + fmt.Sprintf("unrecognised scheme %q", scheme), + ) + return + } + + pol, err := o.Manager.GetActive(c, tenantID, scheme) + o.respondToGet(c, PolicyMediaType, pol, err) +} + +func (o Handler) GetPolicy(c *gin.Context) { + offered := c.NegotiateFormat(PolicyMediaType) + if offered != PolicyMediaType { + reportProblem(c, + http.StatusNotAcceptable, + fmt.Sprintf("the only supported output format is %s", + PolicyMediaType), + ) + return + } + + scheme := c.Param("scheme") + if !o.Manager.IsSchemeSupported(scheme) { + reportProblem(c, + http.StatusBadRequest, + fmt.Sprintf("unrecognised scheme %q", scheme), + ) + return + } + + uuid, err := uuid.Parse(c.Param("uuid")) + if err != nil { + reportProblem(c, + http.StatusBadRequest, + fmt.Sprintf("bad UUID %q", c.Param("uuid")), + ) + return + } + + pol, err := o.Manager.GetPolicy(c, tenantID, scheme, uuid) + o.respondToGet(c, PolicyMediaType, pol, err) +} + +func (o Handler) GetPolicies(c *gin.Context) { + offered := c.NegotiateFormat(PoliciesMediaType) + if offered != PoliciesMediaType { + reportProblem(c, + http.StatusNotAcceptable, + fmt.Sprintf("the only supported output format is %s", + PoliciesMediaType), + ) + return + } + + scheme := c.Param("scheme") + if !o.Manager.IsSchemeSupported(scheme) { + reportProblem(c, + http.StatusBadRequest, + fmt.Sprintf("unrecognised scheme %q", scheme), + ) + return + } + + policies, err := o.Manager.GetPolicies(c, tenantID, scheme, c.Query("name")) + o.respondToGet(c, PoliciesMediaType, policies, err) +} + +func (o Handler) Activate(c *gin.Context) { + scheme := c.Param("scheme") + if !o.Manager.IsSchemeSupported(scheme) { + reportProblem(c, + http.StatusBadRequest, + fmt.Sprintf("unrecognised scheme %q", scheme), + ) + return + } + + uuid, err := uuid.Parse(c.Param("uuid")) + if err != nil { + reportProblem(c, + http.StatusBadRequest, + fmt.Sprintf("bad UUID %q", c.Param("uuid")), + ) + return + } + + err = o.Manager.Activate(c, tenantID, scheme, uuid) + o.respondSimple(c, err) +} + +func (o Handler) DeactivateAll(c *gin.Context) { + scheme := c.Param("scheme") + if !o.Manager.IsSchemeSupported(scheme) { + reportProblem(c, + http.StatusBadRequest, + fmt.Sprintf("unrecognised scheme %q", scheme), + ) + return + } + + err := o.Manager.DeactivateAll(c, tenantID, scheme) + o.respondSimple(c, err) +} + +func (o Handler) respondSimple(c *gin.Context, err error) { + if err == nil { + c.Status(http.StatusOK) + } else { + if errors.Is(err, policy.ErrNoPolicy) { + reportProblem(c, http.StatusNotFound, err.Error()) + } else { + reportProblem(c, http.StatusInternalServerError, err.Error()) + } + } +} + +func (o Handler) GetManagementWellKnownInfo(c *gin.Context) { + offered := c.NegotiateFormat(capability.WellKnownMediaType) + if offered != capability.WellKnownMediaType && offered != gin.MIMEJSON { + reportProblem(c, + http.StatusNotAcceptable, + fmt.Sprintf("the only supported output format is %s", + capability.WellKnownMediaType), + ) + return + } + + obj, err := capability.NewWellKnownInfoObj( + nil, // key + nil, // media types + o.Manager.SupportedSchemes, + config.Version, + "SERVICE_STATUS_READY", + publicApiMap, + ) + + if err != nil { + reportProblem(c, + http.StatusInternalServerError, + err.Error(), + ) + return + } + + c.Header("Content-Type", capability.WellKnownMediaType) + c.JSON(http.StatusOK, obj) + +} + +func (o Handler) respondToGet(c *gin.Context, mt string, ret interface{}, err error) { + if err != nil { + if errors.Is(err, policy.ErrNoPolicy) || errors.Is(err, policy.ErrNoActivePolicy) { + reportProblem(c, http.StatusNotFound, err.Error()) + } else { + reportProblem(c, http.StatusInternalServerError, err.Error()) + } + return + } + + respBytes, err := json.Marshal(ret) + if err != nil { + reportProblem(c, http.StatusInternalServerError, err.Error()) + } + + c.Data(http.StatusOK, mt, respBytes) +} + +func reportProblem(c *gin.Context, status int, details ...string) { + prob := problems.NewStatusProblem(status) + + if len(details) > 0 { + prob.Detail = strings.Join(details, ", ") + } + + log.LogProblem(log.Named("api"), prob) + + c.Header("Content-Type", "application/problem+json") + c.AbortWithStatusJSON(status, prob) +} diff --git a/management/api/router.go b/management/api/router.go new file mode 100644 index 00000000..9338e34c --- /dev/null +++ b/management/api/router.go @@ -0,0 +1,34 @@ +// Copyright 2023 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package api + +import "github.com/gin-gonic/gin" + +var publicApiMap = map[string]string{ + "createPolicy": "/management/v1/policy/:scheme", + "activatePolicy": "/management/v1/policy/:scheme/:uuid/activate", + "getActivePolicy": "/management/v1/policy/:scheme", + "getPolicy": "/management/v1/policy/:scheme/:uuid", + "deactivatePolicies": "/management/v1/policies/:scheme/deactivate", + "getPolicies": "/management/v1/policies/:scheme", +} + +func NewRouter(handler Handler) *gin.Engine { + router := gin.New() + + router.Use(gin.Logger()) + router.Use(gin.Recovery()) + + router.POST(publicApiMap["createPolicy"], handler.CreatePolicy) + router.POST(publicApiMap["activatePolicy"], handler.Activate) + router.GET(publicApiMap["getActivePolicy"], handler.GetActivePolicy) + router.GET(publicApiMap["getPolicy"], handler.GetPolicy) + + router.POST(publicApiMap["deactivatePolicies"], handler.DeactivateAll) + router.GET(publicApiMap["getPolicies"], handler.GetPolicies) + + router.GET("/.well-known/veraison/management", handler.GetManagementWellKnownInfo) + + return router +} diff --git a/management/cmd/management-service/Makefile b/management/cmd/management-service/Makefile new file mode 100644 index 00000000..95239b24 --- /dev/null +++ b/management/cmd/management-service/Makefile @@ -0,0 +1,16 @@ +# Copyright 2022 Contributors to the Veraison project. +# SPDX-License-Identifier: Apache-2.0 + +.DEFAULT_GOAL := all + +GOPKG := github.com/veraison/services/management/cmd/management-service +CMD := management-service +SRCS := main.go + +CMD_DEPS += $(wildcard ../../api/*.go) + +include ../../../mk/common.mk +include ../../../mk/cmd.mk +include ../../../mk/test.mk +include ../../../mk/lint.mk +include ../../../mk/pkg.mk diff --git a/management/cmd/management-service/config.yaml b/management/cmd/management-service/config.yaml new file mode 100644 index 00000000..f81f29a0 --- /dev/null +++ b/management/cmd/management-service/config.yaml @@ -0,0 +1,15 @@ +plugin: + backend: go-plugin + go-plugin: + dir: ../../../scheme/bin/ +po-store: + backend: sql + sql: + driver: sqlite3 + datasource: /veraison/stores/vts/po-store.sql +management: + listen-addr: 0.0.0.0:8088 +po-agent: + backend: opa +logging: + level: debug diff --git a/management/cmd/management-service/main.go b/management/cmd/management-service/main.go new file mode 100644 index 00000000..ba498104 --- /dev/null +++ b/management/cmd/management-service/main.go @@ -0,0 +1,60 @@ +// Copyright 2023 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 +package main + +import ( + _ "github.com/mattn/go-sqlite3" + "github.com/veraison/services/config" + "github.com/veraison/services/log" + "github.com/veraison/services/management" + "github.com/veraison/services/management/api" +) + +var ( + DefaultListenAddr = "localhost:8088" +) + +type cfg struct { + ListenAddr string `mapstructure:"listen-addr" valid:"dialstring"` +} + +func main() { + config.CmdLine() + + v, err := config.ReadRawConfig(*config.File, false) + if err != nil { + log.Fatalf("Could not read config: %v", err) + } + + subs, err := config.GetSubs(v, "*management", "*logging") + if err != nil { + log.Fatalf("Could not parse config: %v", err) + } + + classifiers := map[string]interface{}{"service": "management"} + if err := log.Init(subs["logging"], classifiers); err != nil { + log.Fatalf("could not configure logging: %v", err) + } + log.InitGinWriter() // route gin output to our logger. + + log.Infow("Initializing Management Service", "version", config.Version) + + log.Info("initializing policy manager") + pm, err := management.CreatePolicyManagerFromConfig(v, "policy") + if err != nil { + log.Fatalf("could not init policy manager: %v", err) + } + + cfg := cfg{ListenAddr: DefaultListenAddr} + loader := config.NewLoader(&cfg) + if err := loader.LoadFromViper(subs["management"]); err != nil { + log.Fatalf("Could not load verfication config: %v", err) + + } + + log.Infow("initializing management API service", "address", cfg.ListenAddr) + handler := api.NewHandler(pm, log.Named("api")) + if err := api.NewRouter(handler).Run(cfg.ListenAddr); err != nil { + log.Fatalf("Gin engine failed: %v", err) + } +} diff --git a/management/policy.go b/management/policy.go new file mode 100644 index 00000000..891710af --- /dev/null +++ b/management/policy.go @@ -0,0 +1,206 @@ +// Copyright 2023 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package management + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/spf13/viper" + "github.com/veraison/services/builtin" + "github.com/veraison/services/config" + "github.com/veraison/services/handler" + "github.com/veraison/services/log" + "github.com/veraison/services/plugin" + "github.com/veraison/services/policy" +) + +type PolicyManager struct { + Agent policy.IAgent + Store *policy.Store + SupportedSchemes []string +} + +func CreatePolicyManagerFromConfig(v *viper.Viper, name string) (*PolicyManager, error) { + subs, err := config.GetSubs(v, "*po-agent", "po-store", "*plugin") + if err != nil { + return nil, err + } + + agent, err := policy.CreateAgent(subs["po-agent"], log.Named(name+"-agent")) + if err != nil { + return nil, err + } + + store, err := policy.NewStore(subs["po-store"], log.Named(name+"-store")) + if err != nil { + return nil, err + } + + var pluginManager plugin.IManager[handler.IEvidenceHandler] + + if config.SchemeLoader == "plugins" { // nolint:gocritic + pluginManager, err = plugin.CreateGoPluginManager( + subs["plugin"], log.Named("plugin"), + "evidence-handler", handler.EvidenceHandlerRPC) + if err != nil { + log.Fatalf("plugin manager initialization failed: %v", err) + } + } else if config.SchemeLoader == "builtin" { + pluginManager, err = builtin.CreateBuiltinManager[handler.IEvidenceHandler]( + subs["plugin"], log.Named("builtin"), "evidence-handler") + if err != nil { + log.Fatalf("scheme manager initialization failed: %v", err) + } + } else { + log.Panicw("invalid SchemeLoader value", "SchemeLoader", config.SchemeLoader) + } + defer pluginManager.Close() + + supportedSchemes := pluginManager.GetRegisteredAttestationSchemes() + + return NewPolicyManager(agent, store, supportedSchemes), nil +} + +func NewPolicyManager(agent policy.IAgent, store *policy.Store, schemes []string) *PolicyManager { + return &PolicyManager{Agent: agent, Store: store, SupportedSchemes: schemes} +} + +func (o *PolicyManager) IsSchemeSupported(scheme string) bool { + for _, supported := range o.SupportedSchemes { + if supported == scheme { + return true + } + } + + return false +} + +func (o *PolicyManager) Validate(ctx context.Context, policyRules string) error { + return o.Agent.Validate(ctx, policyRules) +} + +func (o *PolicyManager) Update( + ctx context.Context, + tenantID string, + scheme string, + name string, + rules string, +) (*policy.Policy, error) { + key, err := o.resolvePolicyKey(tenantID, scheme) + if err != nil { + return nil, err + } + + return o.Store.Update(key, name, o.Agent.GetBackendName(), rules) +} + +func (o *PolicyManager) GetActive( + ctx context.Context, + tenantID string, + scheme string, +) (*policy.Policy, error) { + key, err := o.resolvePolicyKey(tenantID, scheme) + if err != nil { + return nil, err + } + + return o.Store.GetActive(key) +} + +func (o *PolicyManager) GetPolicy( + ctx context.Context, + tenantID string, + scheme string, + policyID uuid.UUID, +) (*policy.Policy, error) { + key, err := o.resolvePolicyKey(tenantID, scheme) + if err != nil { + return nil, err + } + + return o.Store.GetPolicy(key, policyID) +} + +func (o *PolicyManager) GetPolicies( + ctx context.Context, + tenantID string, + scheme string, + name string, +) ([]*policy.Policy, error) { + key, err := o.resolvePolicyKey(tenantID, scheme) + if err != nil { + return nil, err + } + + policies, err := o.Store.Get(key) + if err != nil { + return nil, err + } + + if name == "" { + return policies, nil + } + + ret := make([]*policy.Policy, 0) + + for _, pol := range policies { + if pol.Name == name { + ret = append(ret, pol) + } + } + + return ret, nil +} + +func (o *PolicyManager) Activate( + ctx context.Context, + tenantID string, + scheme string, + policyID uuid.UUID, +) error { + key, err := o.resolvePolicyKey(tenantID, scheme) + if err != nil { + return err + } + + return o.Store.Activate(key, policyID) +} + +func (o *PolicyManager) DeactivateAll( + ctx context.Context, + tenantID string, + scheme string, +) error { + key, err := o.resolvePolicyKey(tenantID, scheme) + if err != nil { + return err + } + + return o.Store.DeactivateAll(key) +} + +func (o *PolicyManager) resolvePolicyKey( + tenantID string, + scheme string, +) (policy.PolicyKey, error) { + schemeFound := false + for _, supportedScheme := range o.SupportedSchemes { + if supportedScheme == scheme { + schemeFound = true + break + } + } + + if !schemeFound { + return policy.PolicyKey{}, fmt.Errorf("Unsupported attestation scheme: %q", scheme) + } + + return policy.PolicyKey{ + TenantId: tenantID, + Scheme: scheme, + Name: o.Agent.GetBackendName(), + }, nil +}