diff --git a/.github/scripts/configure_aws.sh b/.github/scripts/configure_aws.sh index 92eb0ceb46..573241ff9e 100755 --- a/.github/scripts/configure_aws.sh +++ b/.github/scripts/configure_aws.sh @@ -1,6 +1,9 @@ #!/bin/bash -environment=$(echo $DEPLOY_ENV | tr a-z A-Z) +environment=STAGING +if [[ $DEPLOY_ENV == "production" ]]; then + environment=PRODUCTION +fi access_key_id_var=${environment}_AWS_ACCESS_KEY_ID access_key_id=$(jq -r ".${access_key_id_var}" <<< $SECRETS) secret_key_var=${environment}_AWS_SECRET_ACCESS_KEY diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2976ab81c5..6aa1fa007e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -47,12 +47,19 @@ jobs: - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v1 + - uses: docker/build-push-action@v2 + with: + context: ./livebook + push: true + tags: ${{ steps.login-ecr.outputs.registry }}/meadow:livebook-${{ env.DEPLOY_ENV }} - uses: docker/build-push-action@v2 with: context: ./app push: true tags: ${{ steps.login-ecr.outputs.registry }}/meadow:${{ env.DEPLOY_ENV }} build-args: | + BUILD_IMAGE=hexpm/elixir:1.15.7-erlang-26.0.2-debian-bullseye-20231009 + RUNTIME_IMAGE=node:18-bullseye-slim HONEYBADGER_API_KEY=${{ secrets.HONEYBADGER_API_KEY }} HONEYBADGER_API_KEY_FRONTEND=${{ secrets.HONEYBADGER_API_KEY_FRONTEND }} HONEYBADGER_ENVIRONMENT=${{ env.DEPLOY_ENV }} diff --git a/.gitignore b/.gitignore index d3e16aef95..73f62afa29 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,4 @@ yarn.lock .DS_Store **/*/.DS_Store -lambdas/stream-authorizer/environment.json +lambdas/stream-authorizer/config diff --git a/app/Dockerfile b/app/Dockerfile index 73887275ec..b774ac0ccf 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,5 +1,8 @@ +ARG BUILD_IMAGE +ARG RUNTIME_IMAGE + # Install elixir & npm dependencies -FROM hexpm/elixir:1.15.4-erlang-26.0.2-alpine-3.18.2 AS build +FROM ${BUILD_IMAGE} AS build LABEL edu.northwestern.library.app=meadow \ edu.northwestern.library.cache=true \ edu.northwestern.library.stage=deps @@ -9,17 +12,19 @@ ARG HONEYBADGER_ENVIRONMENT= ARG HONEYBADGER_REVISION= ARG MEADOW_VERSION= ENV MIX_ENV=prod -RUN apk add --update --repository https://dl-3.alpinelinux.org/alpine/edge/testing/ curl git libstdc++ \ - && mix local.hex --force \ +RUN mix local.hex --force \ && mix local.rebar --force -ENV NODE_VERSION 18.18.0 +ENV NODE_VERSION 18 ENV NPM_VERSION 10.1.0 ENV ARCH x64 -RUN curl -fsSLO --compressed "https://unofficial-builds.nodejs.org/download/release/v$NODE_VERSION/node-v$NODE_VERSION-linux-$ARCH-musl.tar.xz"; \ - tar -xJf "node-v$NODE_VERSION-linux-$ARCH-musl.tar.xz" -C /usr/local --strip-components=1 --no-same-owner \ - && ln -s /usr/local/bin/node /usr/local/bin/nodejs && \ - npm install -g npm@$NPM_VERSION; \ - rm -f "node-v$NODE_VERSION-linux-$ARCH-musl.tar.xz"; +RUN apt update -qq \ + && apt install -y ca-certificates curl git gnupg \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \ + && apt update -qq \ + && apt install -y nodejs \ + && npm install -g npm@$NPM_VERSION COPY . /app WORKDIR /app RUN mix deps.get --only prod \ @@ -39,12 +44,12 @@ WORKDIR /app RUN mix release --overwrite # Create runtime image -FROM node:18-alpine +FROM ${RUNTIME_IMAGE} LABEL edu.northwestern.library.app=meadow \ edu.northwestern.library.stage=runtime -RUN apk update && apk --no-cache --update add curl jq libcrypto3 ncurses-libs openssl-dev +RUN apt update -qq && apt install -y curl jq libssl-dev libncurses5-dev ENV LANG=en_US.UTF-8 -EXPOSE 4000 4369 24601 +EXPOSE 4000 4369 COPY --from=build /app/_build/prod/rel/meadow /app WORKDIR /app ENTRYPOINT ["./bin/meadow"] diff --git a/app/assets/js/__generated__/graphql.ts b/app/assets/js/__generated__/graphql.ts index eb5dc8854c..05c0eb99a3 100644 --- a/app/assets/js/__generated__/graphql.ts +++ b/app/assets/js/__generated__/graphql.ts @@ -913,6 +913,8 @@ export type RootQueryType = { ingestSheetWorkCount?: Maybe; /** Get works created for an Ingest Sheet */ ingestSheetWorks?: Maybe>>; + /** Get the livebook URL */ + livebookUrl?: Maybe; /** Get the currently signed-in user */ me?: Maybe; /** Get an NUL AuthorityRecord by ID */ @@ -1169,6 +1171,8 @@ export type User = { /** Meadow user roles */ export enum UserRole { + /** superuser */ + SuperUser = "SUPERUSER", /** administrator */ Administrator = "ADMINISTRATOR", /** editor */ diff --git a/app/assets/js/components/Auth/DisplayAuthorized.jsx b/app/assets/js/components/Auth/DisplayAuthorized.jsx index bea2ed438d..859441ca40 100644 --- a/app/assets/js/components/Auth/DisplayAuthorized.jsx +++ b/app/assets/js/components/Auth/DisplayAuthorized.jsx @@ -10,7 +10,7 @@ function AuthDisplayAuthorized({ level, children }) { } AuthDisplayAuthorized.propTypes = { - level: PropTypes.oneOf(["USER", "EDITOR", "MANAGER", "ADMINISTRATOR"]), + level: PropTypes.oneOf(["USER", "EDITOR", "MANAGER", "ADMINISTRATOR", "SUPERUSER"]), children: PropTypes.node, }; diff --git a/app/assets/js/components/Livebook/Livebook.jsx b/app/assets/js/components/Livebook/Livebook.jsx new file mode 100644 index 0000000000..1dfdcaa904 --- /dev/null +++ b/app/assets/js/components/Livebook/Livebook.jsx @@ -0,0 +1,23 @@ +import React from "react"; +import { useQuery } from "@apollo/client"; +import { LIVEBOOK_URL } from "@js/components/UI/ui.gql"; + +export default function LivebookLink({children}) { + const { data, loading, error } = useQuery(LIVEBOOK_URL); + + if (error) { + return ( +

+ There was an error retrieving the Livebook url +

+ ); + } + + if (loading) { + return null; + } + + return ( + data?.livebookUrl?.url ? {children} : <> + ) +} \ No newline at end of file diff --git a/app/assets/js/components/UI/Layout/NavBar.jsx b/app/assets/js/components/UI/Layout/NavBar.jsx index d91bd6c14d..44ff2ad922 100644 --- a/app/assets/js/components/UI/Layout/NavBar.jsx +++ b/app/assets/js/components/UI/Layout/NavBar.jsx @@ -4,6 +4,7 @@ import { useLocation } from "react-router-dom"; import { AuthContext } from "../../Auth/Auth"; import client from "../../../client"; import UISearchBar from "../SearchBar"; +import UILivebookLink from "@js/components/Livebook/Livebook"; import UILayoutNavDropdown from "@js/components/UI/Layout/NavDropdown"; import UILayoutNavDropdownHeader from "@js/components/UI/Layout/NavDropdownHeader"; import UILayoutNavDropdownBody from "@js/components/UI/Layout/NavDropdownBody"; @@ -157,6 +158,15 @@ const UILayoutNavBar = () => { + + + + }> + Livebook + + + + }> diff --git a/app/assets/js/components/UI/ui.gql.js b/app/assets/js/components/UI/ui.gql.js index dc6c2e27bc..dcf6986838 100644 --- a/app/assets/js/components/UI/ui.gql.js +++ b/app/assets/js/components/UI/ui.gql.js @@ -7,3 +7,11 @@ export const DIGITAL_COLLECTIONS_URL = gql` } } `; + +export const LIVEBOOK_URL = gql` + query LivebookUrl { + livebookUrl { + url + } + } +` \ No newline at end of file diff --git a/app/assets/js/hooks/useIsAuthorized.js b/app/assets/js/hooks/useIsAuthorized.js index 379f376fb9..8f053ebc53 100644 --- a/app/assets/js/hooks/useIsAuthorized.js +++ b/app/assets/js/hooks/useIsAuthorized.js @@ -10,7 +10,7 @@ import { Notification } from "@nulib/design-system"; */ // Order of role-based access low to high -const userRoleHierarchy = ["USER", "EDITOR", "MANAGER", "ADMINISTRATOR"]; +const userRoleHierarchy = ["USER", "EDITOR", "MANAGER", "ADMINISTRATOR", "SUPERUSER"]; export default function useIsAuthorized() { const { data, loading, error } = useQuery(GET_CURRENT_USER_QUERY); diff --git a/app/config/releases.exs b/app/config/releases.exs index d933d3c764..1f7e8ff8b8 100644 --- a/app/config/releases.exs +++ b/app/config/releases.exs @@ -116,6 +116,8 @@ config :meadow, preservation_check_bucket: aws_secret("meadow", dig: ["buckets", "preservation_check"]), streaming_bucket: aws_secret("meadow", dig: ["buckets", "streaming"]) +config :meadow, :livebook, url: environment_secret("LIVEBOOK_URL", default: nil) + config :logger, level: :info config :meadow, Meadow.Scheduler, @@ -178,7 +180,10 @@ config :hackney, processors: [ default: [ concurrency: - environment_secret("#{key}_PROCESSOR_CONCURRENCY", cast: :integer, default: 10), + environment_secret("#{key}_PROCESSOR_CONCURRENCY", + cast: :integer, + default: 10 + ), max_demand: environment_secret("#{key}_MAX_DEMAND", cast: :integer, default: 10), min_demand: environment_secret("#{key}_MIN_DEMAND", cast: :integer, default: 5) diff --git a/app/lib/meadow/constants.ex b/app/lib/meadow/constants.ex index 57f5835aa5..5a28ab5090 100644 --- a/app/lib/meadow/constants.ex +++ b/app/lib/meadow/constants.ex @@ -5,7 +5,7 @@ defmodule Meadow.Constants do quote do @ingest_sheet_headers ~w(description file_accession_number filename label role structure work_accession_number work_image work_type) - @role_priority ~w[Administrators Managers Editors Users] + @role_priority ~w[SuperUsers Administrators Managers Editors Users] end end end diff --git a/app/lib/meadow/roles.ex b/app/lib/meadow/roles.ex index 73e0802320..2ae06454ca 100644 --- a/app/lib/meadow/roles.ex +++ b/app/lib/meadow/roles.ex @@ -11,6 +11,12 @@ defmodule Meadow.Roles do iex> authorized?("User", :any) true + iex> authorized?("SuperUser", "SuperUser") + true + + iex> authorized?("Administrator", "SuperUser") + false + iex> authorized?("Administrator", "User") true @@ -30,6 +36,8 @@ defmodule Meadow.Roles do def authorized?(nil, _), do: false def authorized?(_, :any), do: true + def authorized?("SuperUser", _role), do: true + def authorized?("Administrator", "SuperUser"), do: false def authorized?("Administrator", _role), do: true def authorized?("Manager", "Editor"), do: true def authorized?("Manager", "User"), do: true diff --git a/app/lib/meadow_web/resolvers/helpers.ex b/app/lib/meadow_web/resolvers/helpers.ex index 49b59e9e0c..8f7fcf2f38 100644 --- a/app/lib/meadow_web/resolvers/helpers.ex +++ b/app/lib/meadow_web/resolvers/helpers.ex @@ -42,6 +42,10 @@ defmodule MeadowWeb.Resolvers.Helpers do {:ok, %{url: Config.iiif_server_url()}} end + def get_livebook_url(_, _, _) do + {:ok, %{url: Application.get_env(:meadow, :livebook, []) |> Keyword.get(:url)}} + end + def dcapi_endpoint(_, _args, _) do {:ok, %{url: Application.get_env(:meadow, :dc_api) |> get_in([:v2, "base_url"])}} end diff --git a/app/lib/meadow_web/schema/schema.ex b/app/lib/meadow_web/schema/schema.ex index 06390a788b..44e034dd54 100644 --- a/app/lib/meadow_web/schema/schema.ex +++ b/app/lib/meadow_web/schema/schema.ex @@ -67,6 +67,10 @@ defmodule MeadowWeb.Schema do field :expires, non_null(:datetime) end + object :nullable_url do + field :url, :string + end + object :url do field :url, non_null(:string) end diff --git a/app/lib/meadow_web/schema/types/account_types.ex b/app/lib/meadow_web/schema/types/account_types.ex index 555818ce32..0eebc91c03 100644 --- a/app/lib/meadow_web/schema/types/account_types.ex +++ b/app/lib/meadow_web/schema/types/account_types.ex @@ -73,6 +73,7 @@ defmodule MeadowWeb.Schema.AccountTypes do @desc "Meadow user roles" enum :user_role do + value(:superuser, as: "SuperUser", description: "superuser") value(:administrator, as: "Administrator", description: "administrator") value(:manager, as: "Manager", description: "manager") value(:editor, as: "Editor", description: "editor") diff --git a/app/lib/meadow_web/schema/types/helper_types.ex b/app/lib/meadow_web/schema/types/helper_types.ex index dee3bba6eb..f458c618c2 100644 --- a/app/lib/meadow_web/schema/types/helper_types.ex +++ b/app/lib/meadow_web/schema/types/helper_types.ex @@ -45,6 +45,12 @@ defmodule MeadowWeb.Schema.HelperTypes do middleware(Middleware.Authenticate) resolve(&Resolvers.Helpers.work_archiver_endpoint/3) end + + @desc "Get the livebook URL" + field :livebook_url, :nullable_url do + middleware(Middleware.Authenticate) + resolve(&Resolvers.Helpers.get_livebook_url/3) + end end enum :s3_upload_type do diff --git a/app/priv/graphql/schema.json b/app/priv/graphql/schema.json index 49ba16e186..ab3a764958 100644 --- a/app/priv/graphql/schema.json +++ b/app/priv/graphql/schema.json @@ -7298,6 +7298,18 @@ } } }, + { + "args": [], + "deprecationReason": null, + "description": "Get the livebook URL", + "isDeprecated": false, + "name": "livebookUrl", + "type": { + "kind": "OBJECT", + "name": "Url", + "ofType": null + } + }, { "args": [], "deprecationReason": null, @@ -8313,6 +8325,12 @@ { "description": "Meadow user roles", "enumValues": [ + { + "deprecationReason": null, + "description": "superuser", + "isDeprecated": false, + "name": "SUPERUSER" + }, { "deprecationReason": null, "description": "administrator", diff --git a/app/rel/env.sh.eex b/app/rel/env.sh.eex index 9482a5d344..7db9aec994 100644 --- a/app/rel/env.sh.eex +++ b/app/rel/env.sh.eex @@ -6,7 +6,7 @@ if [ "$ECS_CONTAINER_METADATA_URI" != "" ]; then fi # Set the release to work across nodes -export RELEASE_DISTRIBUTION=name -export RELEASE_NODE=<%= @release.name %>@${IP_ADDR} +export RELEASE_DISTRIBUTION=${RELEASE_DISTRIBUTION:-name} +export RELEASE_NODE=${RELEASE_NODE:-<%= @release.name %>@${IP_ADDR}} echo "Erlang node name: ${RELEASE_NODE}" diff --git a/infrastructure/deploy/ecs.tf b/infrastructure/deploy/ecs.tf index 3be20c6f69..5ddc4caaa3 100644 --- a/infrastructure/deploy/ecs.tf +++ b/infrastructure/deploy/ecs.tf @@ -105,12 +105,20 @@ resource "aws_security_group" "meadow_load_balancer" { } ingress { - description = "HTTPS in" + description = "HTTPS in (Meadow)" from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } + + ingress { + description = "HTTPS in (Livebook)" + from_port = 8080 + to_port = 8080 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } } data "aws_iam_policy" "ecs_exec_command" { @@ -171,6 +179,27 @@ resource "aws_lb_target_group" "meadow_target" { } } +resource "aws_lb_target_group" "meadow_livebook_target" { + port = 8080 + deregistration_delay = 30 + target_type = "ip" + protocol = "HTTP" + vpc_id = data.aws_vpc.this_vpc.id + tags = var.tags + + health_check { + path = "/" + matcher = "200,403" + healthy_threshold = 2 + unhealthy_threshold = 2 + } + + stickiness { + enabled = false + type = "lb_cookie" + } +} + resource "aws_lb" "meadow_load_balancer" { name = "${var.stack_name}-lb" internal = false @@ -210,6 +239,19 @@ resource "aws_lb_listener" "meadow_lb_listener_https" { } } +resource "aws_lb_listener" "meadow_lb_listener_livebook" { + load_balancer_arn = aws_lb.meadow_load_balancer.arn + port = 8080 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-2016-08" + certificate_arn = data.aws_acm_certificate.meadow_cert.arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.meadow_livebook_target.arn + } +} + resource "random_string" "secret_key_base" { length = "64" special = "false" diff --git a/infrastructure/deploy/ecs_services.tf b/infrastructure/deploy/ecs_services.tf index a916f9de72..581d1a3d78 100644 --- a/infrastructure/deploy/ecs_services.tf +++ b/infrastructure/deploy/ecs_services.tf @@ -1,5 +1,5 @@ locals { - container_ports = tolist([4000, 4369, 24601]) + container_ports = tolist([4000, 4369, 8080, 24601]) meadow_urls = [for hostname in concat([aws_route53_record.app_hostname.fqdn], var.additional_hostnames) : "//${hostname}"] @@ -7,6 +7,7 @@ locals { docker_tag = terraform.workspace honeybadger_api_key = var.honeybadger_api_key host_name = aws_route53_record.app_hostname.fqdn + internal_host_name = "${var.stack_name}.${data.aws_service_discovery_dns_namespace.internal_dns_zone.name}" log_group = aws_cloudwatch_log_group.meadow_logs.name meadow_urls = join(",", local.meadow_urls) region = var.aws_region @@ -27,6 +28,27 @@ module "meadow_task_all" { tags = var.tags } +data "aws_service_discovery_dns_namespace" "internal_dns_zone" { + name = "internal.${var.dns_zone}" + type = "DNS_PRIVATE" +} + +resource "aws_service_discovery_service" "meadow" { + name = "meadow" + + dns_config { + namespace_id = data.aws_service_discovery_dns_namespace.internal_dns_zone.id + dns_records { + ttl = 10 + type = "A" + } + + routing_policy = "MULTIVALUE" + } + + tags = var.tags +} + resource "aws_ecs_service" "meadow_all" { name = "meadow" cluster = aws_ecs_cluster.meadow.id @@ -38,12 +60,22 @@ resource "aws_ecs_service" "meadow_all" { depends_on = [aws_lb.meadow_load_balancer] platform_version = "1.4.0" + service_registries { + registry_arn = aws_service_discovery_service.meadow.arn + } + load_balancer { target_group_arn = aws_lb_target_group.meadow_target.arn container_name = "meadow" container_port = 4000 } + load_balancer { + target_group_arn = aws_lb_target_group.meadow_livebook_target.arn + container_name = "livebook" + container_port = 8080 + } + lifecycle { ignore_changes = [desired_count] } diff --git a/infrastructure/deploy/modules/meadow_task/main.tf b/infrastructure/deploy/modules/meadow_task/main.tf index b4e9551be1..dc144e3ec0 100644 --- a/infrastructure/deploy/modules/meadow_task/main.tf +++ b/infrastructure/deploy/modules/meadow_task/main.tf @@ -22,12 +22,10 @@ locals { container_vars = merge( var.container_config, { - cpu_reservation = var.cpu * 0.9765625, db_pool_size = var.db_pool_size, db_queue_interval = var.db_queue_interval, db_queue_target = var.db_queue_target, docker_repository = data.aws_ecr_repository.meadow.repository_url, - memory_reservation = var.memory * 0.9765625, name = var.name, processes = var.meadow_processes } diff --git a/infrastructure/deploy/stream_authorizer.tf b/infrastructure/deploy/stream_authorizer.tf index aed4537f48..5f24fd5947 100644 --- a/infrastructure/deploy/stream_authorizer.tf +++ b/infrastructure/deploy/stream_authorizer.tf @@ -56,10 +56,11 @@ locals { auth_source_path = "${path.module}/../../lambdas/stream-authorizer" auth_allowed_referers = var.trusted_referers auth_dc_api = var.dc_api_v2_base + source_files = fileset(path.module, "../../lambdas/stream-authorizer/*.{js,json}") auth_source_sha = sha1( join( "", - concat([for f in fileset(path.module, "../../lambdas/stream-authorizer/*.{js,json}") : sha1(file(f))], [sha1(local.auth_allowed_referers), sha1(local.auth_dc_api)]) + concat([for f in local.source_files : sha1(file(f))], [sha1(local.auth_allowed_referers), sha1(local.auth_dc_api)]) ) ) } @@ -82,7 +83,7 @@ resource "local_file" "stream_authorizer_configuration" { allowedFrom = local.auth_allowed_referers dcApiEndpoint = local.auth_dc_api }) - filename = "${local.auth_source_path}/environment.json" + filename = "${local.auth_source_path}/config/environment.json" } data "archive_file" "stream_authorizer_lambda" { diff --git a/infrastructure/deploy/task-definitions/meadow_app.json b/infrastructure/deploy/task-definitions/meadow_app.json index 0b3e0ed9cf..4e5f4033aa 100644 --- a/infrastructure/deploy/task-definitions/meadow_app.json +++ b/infrastructure/deploy/task-definitions/meadow_app.json @@ -2,8 +2,7 @@ { "name": "meadow", "image": "${docker_repository}:${docker_tag}", - "cpu": ${cpu_reservation}, - "memoryReservation": ${memory_reservation}, + "cpu": 0, "mountPoints": [], "essential": true, "environment": [ @@ -19,6 +18,10 @@ "name": "HONEYBADGER_API_KEY", "value": "${honeybadger_api_key}" }, + { + "name": "LIVEBOOK_URL", + "value": "https://${host_name}:8080" + }, { "name": "MEADOW_HOSTNAME", "value": "${host_name}" @@ -47,6 +50,10 @@ "name": "RELEASE_DISTRIBUTION", "value": "name" }, + { + "name": "RELEASE_NODE", + "value": "meadow@${internal_host_name}" + }, { "name": "ALLOWED_ORIGINS", "value": "${meadow_urls}" @@ -102,7 +109,8 @@ { "name": "FILE_SET_COMPLETE_PROCESSOR_CONCURRENCY", "value": "20" - } ], + } + ], "portMappings": [ { "containerPort": 4000, @@ -136,5 +144,61 @@ } ], "volumesFrom": [] + }, + { + "name": "livebook", + "image": "${docker_repository}:livebook-${docker_tag}", + "cpu": 512, + "memoryReservation": 512, + "portMappings": [ + { + "name": "livebook-8080-tcp", + "containerPort": 8080, + "hostPort": 8080, + "protocol": "tcp", + "appProtocol": "http" + }, + { + "name": "livebook-8081-tcp", + "containerPort": 8081, + "hostPort": 8081, + "protocol": "tcp", + "appProtocol": "http" + } + ], + "essential": true, + "environment": [ + { + "name": "LB_MEADOW_COOKIE", + "value": "${secret_key_base}" + }, + { + "name": "LB_MEADOW_NODE", + "value": "meadow@${internal_host_name}" + }, + { + "name": "LIVEBOOK_NODE", + "value": "livebook@${internal_host_name}" + }, + { + "name": "LIVEBOOK_DISTRIBUTION", + "value": "name" + }, + { + "name": "LIVEBOOK_COOKIE", + "value": "${secret_key_base}" + } + ], + "mountPoints": [], + "volumesFrom": [], + "readonlyRootFilesystem": false, + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "${log_group}", + "awslogs-region": "${region}", + "awslogs-stream-prefix": "livebook" + } + } } ] diff --git a/lambdas/stream-authorizer/authorize.js b/lambdas/stream-authorizer/authorize.js index 75a1beecfd..fdd28e7d43 100644 --- a/lambdas/stream-authorizer/authorize.js +++ b/lambdas/stream-authorizer/authorize.js @@ -1,6 +1,6 @@ const isString = require("lodash.isstring"); const fetch = require("node-fetch"); -const { dcApiEndpoint, allowedFrom } = require("./environment.json"); +const { dcApiEndpoint, allowedFrom } = require("./config/environment.json"); const allowedFromRegexes = ((str) => { const configValues = isString(str) ? str.split(";") : []; diff --git a/livebook/Dockerfile b/livebook/Dockerfile new file mode 100644 index 0000000000..c13b15ce76 --- /dev/null +++ b/livebook/Dockerfile @@ -0,0 +1,8 @@ +FROM ghcr.io/livebook-dev/livebook:edge +ENV LIVEBOOK_IDENTITY_PROVIDER=custom:MeadowLivebookAuth +ENV LIVEBOOK_DISTRIBUTION=sname +ENV LIVEBOOK_IP=0.0.0.0 +ENV LIVEBOOK_DATA_PATH=/data +ENV LIVEBOOK_HOME=${LIVEBOOK_DATA_PATH}/books +RUN mkdir -p ${LIVEBOOK_DATA_PATH}/books +ADD ./meadow_livebook_auth.exs /app/user/extensions/meadow_livebook_auth.exs diff --git a/livebook/meadow_livebook_auth.exs b/livebook/meadow_livebook_auth.exs new file mode 100644 index 0000000000..8e978cf1be --- /dev/null +++ b/livebook/meadow_livebook_auth.exs @@ -0,0 +1,67 @@ +defmodule MeadowLivebookAuth do + @moduledoc """ + Custom authentication module for Livebook that passes an existing Meadow + session cookie to Meadow's GraphQL endpoint to find out if the current + user is a Meadow SuperUser. Only works if Livebook and Meadow are running + on the same hostname. + """ + use GenServer + + @query "query { me { displayName email role username } }" + + @spec start_link(keyword) :: {:ok, pid()} + def start_link(opts) do + identity_key = opts[:identity_key] + GenServer.start_link(__MODULE__, identity_key, Keyword.take(opts, [:name])) + end + + def init(init_arg) do + Application.put_env(:livebook, :authentication_mode, :disabled) + {:ok, init_arg} + end + + @spec authenticate(GenServer.server(), Plug.Conn.t(), keyword()) :: + {Plug.Conn.t(), map() | nil} + def authenticate(server, conn, _) do + with [_ | [host | _]] <- Node.self() |> to_string() |> String.split("@"), + url <- "http://#{host}:4000/api/graphql" do + set_state(server, :auth_url, url) + {conn, meadow_auth(url, conn)} + end + end + + defp meadow_auth(nil, _), do: nil + + defp meadow_auth(url, conn) do + with meadow_cookie <- + conn |> Plug.Conn.fetch_cookies() |> Map.get(:cookies) |> Map.get("_meadow_key") do + Req.get(url, + body: @query, + headers: ["Content-Type": "application/graphql", Cookie: "_meadow_key=#{meadow_cookie}"] + ) + |> process_auth_response() + end + end + + defp process_auth_response( + {:ok, %{status: 200, body: %{"data" => %{"me" => %{"role" => "SUPERUSER"} = user}}}} + ) do + %{id: user["username"], name: user["displayName"], email: user["email"]} + end + + defp process_auth_response(_), do: nil + + def get_state(server) do + case :sys.get_state(server) do + nil -> %{} + map -> map + end + end + + def set_state(server, key, value) do + :sys.replace_state(server, fn + nil -> %{key => value} + map -> Map.put(map, key, value) + end) + end +end