diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index d237c02..937c979 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -27,7 +27,7 @@ services: - ../policy/:/policy:cached,z env_file: opa.env environment: - JWKS_ENDPOINT: https://authn.diamond.ac.uk/realms/master/protocol/openid-connect/certs + SKIP_AUTHORIZATION: "true" ispyb: image: ghcr.io/diamondlightsource/ispyb-database:v3.0.0 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1556031..10a7531 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,13 +4,40 @@ updates: directory: / schedule: interval: weekly + groups: + github-artifacts: + patterns: + - actions/*-artifact + minor: + update-types: + - minor + - patch - package-ecosystem: devcontainers - directory: "/" + directory: / + schedule: + interval: weekly + groups: + minor: + update-types: + - minor + - patch + + - package-ecosystem: docker + directory: / schedule: interval: weekly + groups: + minor: + update-types: + - minor + - patch - package-ecosystem: cargo directory: / schedule: interval: weekly + groups: + patch: + update-types: + - patch diff --git a/.github/workflows/code.yml b/.github/workflows/code.yml index b6edbd5..e35ae89 100644 --- a/.github/workflows/code.yml +++ b/.github/workflows/code.yml @@ -22,7 +22,7 @@ jobs: DATABASE_URL: mysql://root:rootpassword@localhost/ispyb_build steps: - name: Checkout source - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4.1.2 - name: Install dependencies uses: awalsh128/cache-apt-pkgs-action@v1.4.2 @@ -79,7 +79,7 @@ jobs: DATABASE_URL: mysql://root:rootpassword@localhost/ispyb_build steps: - name: Checkout source - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4.1.2 - name: Install dependencies uses: awalsh128/cache-apt-pkgs-action@v1.4.2 diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index 44a4196..3730815 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -23,14 +23,14 @@ jobs: packages: write steps: - name: Checkout Code - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4.1.2 - name: Generate Image Name run: echo IMAGE_REPOSITORY=ghcr.io/$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]' | tr '[_]' '[\-]') >> $GITHUB_ENV - name: Log in to GitHub Docker Registry if: github.event_name != 'pull_request' - uses: docker/login-action@v3.0.0 + uses: docker/login-action@v3.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -46,12 +46,12 @@ jobs: type=raw,value=latest - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.1.0 + uses: docker/setup-buildx-action@v3.2.0 with: driver-opts: network=host - name: Build Image - uses: docker/build-push-action@v5.2.0 + uses: docker/build-push-action@v5.3.0 with: build-args: DATABASE_URL=mysql://root:rootpassword@localhost:3306/ispyb_build target: deploy diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index f409bcb..73e6d47 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -11,13 +11,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4.1.2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.1.0 + uses: docker/setup-buildx-action@v3.2.0 - name: Create .env file run: touch .devcontainer/opa.env - name: Build dev container - uses: devcontainers/ci@v0.3.1900000347 + uses: devcontainers/ci@v0.3.1900000348 diff --git a/.github/workflows/policy.yml b/.github/workflows/policy.yml new file mode 100644 index 0000000..d582ca3 --- /dev/null +++ b/.github/workflows/policy.yml @@ -0,0 +1,75 @@ +name: Policy + +on: + push: + pull_request: + +jobs: + lint: + # Deduplicate jobs from pull requests and branch pushes within the same repo. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v4.1.2 + + - name: Setup Regal + uses: StyraInc/setup-regal@v1.0.0 + with: + version: latest + + - name: Lint + run: regal lint --format github ./policy + + test: + # Deduplicate jobs from pull requests and branch pushes within the same repo. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v4.1.2 + + - name: Setup OPA + uses: open-policy-agent/setup-opa@v2.2.0 + with: + version: latest + + - name: Test + run: opa test ./policy -v + + build_bundle: + needs: + - lint + - test + # Deduplicate jobs from pull requests and branch pushes within the same repo. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout source + uses: actions/checkout@v4.1.2 + + - name: Generate Image Name + run: echo IMAGE_REPOSITORY=ghcr.io/$(echo "${{ github.repository }}-policy" | tr '[:upper:]' '[:lower:]' | tr '[_]' '[\-]') >> $GITHUB_ENV + + - name: Log in to GitHub Docker Registry + uses: docker/login-action@v3.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup OPA + uses: open-policy-agent/setup-opa@v2.2.0 + with: + version: latest + + - name: Build OPA Policy # If this is a tag, use it as a revision string + run: opa build -b policy -r ${{ github.ref_name }} --ignore *_test.rego + + - name: Publish OPA Bundle + if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags') }} + run: oras push ${{ env.IMAGE_REPOSITORY }}:${{ github.ref_name }} bundle.tar.gz:application/vnd.oci.image.layer.v1.tar+gzip + diff --git a/.github/workflows/schema.yml b/.github/workflows/schema.yml new file mode 100644 index 0000000..ead6cb6 --- /dev/null +++ b/.github/workflows/schema.yml @@ -0,0 +1,83 @@ +name: Schema + +on: + push: + pull_request: + +jobs: + generate: + # Deduplicate jobs from pull requests and branch pushes within the same repo. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + services: + ispyb: + image: ghcr.io/diamondlightsource/ispyb-database:v3.0.0 + ports: + - 3306:3306 + env: + MARIADB_ROOT_PASSWORD: rootpassword + options: > + --health-cmd "/usr/local/bin/healthcheck.sh --defaults-file=/ispyb/.my.cnf --connect" + env: + DATABASE_URL: mysql://root:rootpassword@localhost:3306/ispyb_build + steps: + - name: Checkout source + uses: actions/checkout@v4.1.2 + + - name: Install stable toolchain + uses: actions-rs/toolchain@v1.0.7 + with: + toolchain: stable + default: true + + - name: Cache Rust Build + uses: Swatinem/rust-cache@v2.7.3 + + - name: Generate Schema + uses: actions-rs/cargo@v1.0.3 + with: + command: run + args: > + schema + --path sessions.graphql + + - name: Upload Schema Artifact + uses: actions/upload-artifact@v4.3.1 + with: + name: sessions.graphql + path: sessions.graphql + + publish: + # Deduplicate jobs from pull requests and branch pushes within the same repo. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository + needs: + - generate + runs-on: ubuntu-latest + steps: + - name: Install Rover CLI + run: | + curl -sSL https://rover.apollo.dev/nix/v0.23.0-rc.3 | sh + echo "$HOME/.rover/bin" >> $GITHUB_PATH + + - name: Download Schema Artifact + uses: actions/download-artifact@v4.1.4 + with: + name: sessions.graphql + + - name: Check Subgraph Schema + run: > + rover subgraph check data-gateway-n63jcf@current + --schema sessions.graphql + --name sessions + env: + APOLLO_KEY: ${{ secrets.APOLLO_STUDIO }} + + - name: Publish Subgraph Schema to Apollo Studio + if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags') }} + run: > + rover subgraph publish data-gateway-n63jcf@current + --routing-url http://sessions:80 + --schema sessions.graphql + --name sessions + env: + APOLLO_KEY: ${{ secrets.APOLLO_STUDIO }} diff --git a/models/build.rs b/models/build.rs index 5c6c62f..9f3fb5c 100644 --- a/models/build.rs +++ b/models/build.rs @@ -28,7 +28,7 @@ const TABLES_SPECS: &[&Table] = &[ }, &Table { name: "Proposal", - columns: &["proposalId", "proposalNumber"], + columns: &["proposalId", "proposalCode", "proposalNumber"], }, ]; diff --git a/policy/system.rego b/policy/system.rego index 0b9d475..debd5db 100644 --- a/policy/system.rego +++ b/policy/system.rego @@ -1,11 +1,31 @@ package system +import data.token import rego.v1 +# METADATA +# description: Allow subjects on session or containing proposal +# entrypoint: true main := {"allow": allow} default allow := false +# Allow if the SKIP_AUTHORIZATION environment variable is set and a preset token is supplied allow if { - input.token == "ValidToken" + opa.runtime().env.SKIP_AUTHORIZATION + input.token == "ValidToken" +} + +# Allow if on proposal which contains session +allow if { + some proposal_number in data.diamond.data.subjects[token.claims.fedid].proposals + proposal_number == input.proposal +} + +# Allow if directly on session +allow if { + some session_id in data.diamond.data.subjects[token.claims.fedid].sessions + session := data.diamond.data.sessions[session_id] + session.proposal_number == input.proposal + session.visit_number == input.visit } diff --git a/policy/token.rego b/policy/token.rego new file mode 100644 index 0000000..f9e4520 --- /dev/null +++ b/policy/token.rego @@ -0,0 +1,26 @@ +package token + +import rego.v1 + +fetch_jwks(url) := http.send({ + "url": jwks_url, + "method": "GET", + "force_cache": true, + "force_cache_duration_seconds": 3600, +}) + +jwks_endpoint := opa.runtime().env.JWKS_ENDPOINT + +unverified := io.jwt.decode(input.token) + +jwt_header := unverified[0] + +jwks_url := concat("?", [jwks_endpoint, urlquery.encode_object({"kid": jwt_header.kid})]) + +jwks := fetch_jwks(jwks_url).raw_body + +verified := unverified if { + io.jwt.verify_rs256(input.token, jwks) +} + +claims := verified[1] diff --git a/sessions/src/graphql.rs b/sessions/src/graphql.rs index 318cec8..108ea91 100644 --- a/sessions/src/graphql.rs +++ b/sessions/src/graphql.rs @@ -1,40 +1,66 @@ use crate::opa::{OpaClient, OpaInput}; use async_graphql::{ - Context, EmptyMutation, EmptySubscription, Object, Schema, SchemaBuilder, SimpleObject, + ComplexObject, Context, EmptyMutation, EmptySubscription, Object, Schema, SchemaBuilder, + SimpleObject, }; use chrono::{DateTime, Utc}; use models::{bl_session, proposal}; -use sea_orm::{ - ColumnTrait, Condition, DatabaseConnection, EntityTrait, JoinType, QueryFilter, QuerySelect, -}; +use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter}; use serde::Serialize; +use tracing::instrument; /// The GraphQL schema exposed by the service pub type RootSchema = Schema; /// A schema builder for the service pub fn root_schema_builder() -> SchemaBuilder { - Schema::build(RootQuery, EmptyMutation, EmptySubscription) + Schema::build(RootQuery, EmptyMutation, EmptySubscription).enable_federation() } /// A Beamline Session #[derive(Debug, SimpleObject)] +#[graphql(complex)] struct Session { - /// The number of session within the Proposal - visit_number: Option, - /// The date and time at which the Session began - start: Option>, - /// The date and time at which the Session ended - end: Option>, + /// The underlying database model + #[graphql(skip)] + session: bl_session::Model, + /// The proposal information + proposal: Option, } -impl From for Session { - fn from(value: bl_session::Model) -> Self { - Self { - visit_number: value.visit_number, - start: value.start_date.map(|date| date.and_utc()), - end: value.end_date.map(|date| date.and_utc()), - } +#[ComplexObject] +impl Session { + async fn visit_number(&self, _ctx: &Context<'_>) -> u32 { + self.session.visit_number.unwrap_or_default() + } + + async fn start(&self, _ctx: &Context<'_>) -> Option> { + self.session.start_date.map(|date| date.and_utc()) + } + + async fn end(&self, _ctx: &Context<'_>) -> Option> { + self.session.end_date.map(|date| date.and_utc()) + } +} + +/// An Experimental Proposal, containing numerous sessions +#[derive(Debug)] +struct Proposal(proposal::Model); + +#[Object] +impl Proposal { + async fn code(&self, _ctx: &Context<'_>) -> &Option { + &self.0.proposal_code + } + + /// A unique number identifying the Proposal + async fn number(&self, _ctx: &Context<'_>) -> Result, async_graphql::Error> { + Ok(self + .0 + .proposal_number + .as_ref() + .map(|num| num.parse()) + .transpose()?) } } @@ -54,6 +80,7 @@ struct OpaSessionParameters { #[Object] impl RootQuery { /// Retrieves a Beamline Session + #[instrument(name = "query_session", skip(ctx))] async fn session( &self, ctx: &Context<'_>, @@ -68,13 +95,7 @@ impl RootQuery { )?) .await?; Ok(bl_session::Entity::find() - .join_rev( - JoinType::InnerJoin, - proposal::Entity::has_many(bl_session::Entity) - .from(proposal::Column::ProposalId) - .to(bl_session::Column::ProposalId) - .into(), - ) + .find_also_related(proposal::Entity) .filter( Condition::all() .add(bl_session::Column::VisitNumber.eq(visit)) @@ -82,6 +103,20 @@ impl RootQuery { ) .one(database) .await? - .map(Session::from)) + .map(|(session, proposal)| Session { + session, + proposal: proposal.map(Proposal), + })) + } + + /// Retrieves a Beamline Session + #[graphql(entity)] + async fn router_session( + &self, + ctx: &Context<'_>, + proposal: u32, + visit: u32, + ) -> Result, async_graphql::Error> { + self.session(ctx, proposal, visit).await } } diff --git a/sessions/src/main.rs b/sessions/src/main.rs index 12fae42..4a234c6 100644 --- a/sessions/src/main.rs +++ b/sessions/src/main.rs @@ -17,7 +17,7 @@ use crate::{ opa::OpaClient, route_handlers::GraphQLHandler, }; -use async_graphql::{extensions::Tracing, http::GraphiQLSource}; +use async_graphql::{extensions::Tracing, http::GraphiQLSource, SDLExportOptions}; use axum::{response::Html, routing::get, Router}; use clap::Parser; use opentelemetry_otlp::WithExportConfig; @@ -93,7 +93,7 @@ async fn main() { } Cli::Schema(args) => { let schema = root_schema_builder().finish(); - let schema_string = schema.sdl(); + let schema_string = schema.sdl_with_options(SDLExportOptions::new().federation()); if let Some(path) = args.path { let mut file = File::create(path).unwrap(); file.write_all(schema_string.as_bytes()).unwrap();