diff --git a/Dockerfile b/Dockerfile index a9bff8391..2a9485842 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,18 @@ # syntax=docker/dockerfile:1.3.1 -FROM ubuntu:20.04 AS build -SHELL ["/bin/bash", "-c"] -ENV GVM_VERSION=master -COPY ./go.mod ./go.mod -RUN apt-get update && \ - apt-get -y install gcc bison binutils make git gcc curl build-essential mercurial ca-certificates -RUN bash < <(curl -SL -v https://raw.githubusercontent.com/moovweb/gvm/${GVM_VERSION}/binscripts/gvm-installer) && \ - source /root/.gvm/scripts/gvm && \ - gvm install go$(cat go.mod | sed -n "/^go/p" | cut -d ' ' -f 2) -B && \ - gvm use go$(cat go.mod | sed -n "/^go/p" | cut -d ' ' -f 2) --default - - -FROM build as build-env +FROM ubuntu:20.04 as build-env ARG GIT_COMMIT ARG VERSION +ARG GO_VERSION WORKDIR /usr/src/jimm SHELL ["/bin/bash", "-c"] COPY . . +RUN apt update && apt install wget gcc -y +RUN wget -L "https://golang.org/dl/go${GO_VERSION}.linux-amd64.tar.gz" +RUN tar -C /usr/local -xzf "go${GO_VERSION}.linux-amd64.tar.gz" +ENV PATH="${PATH}:/usr/local/go/bin" RUN echo "${GIT_COMMIT}" | tee ./version/commit.txt RUN echo "${VERSION}" | tee ./version/version.txt -RUN --mount=type=ssh source /root/.gvm/scripts/gvm && go mod vendor -RUN --mount=type=ssh source /root/.gvm/scripts/gvm && go build -tags version -o jimmsrv -v -a -mod vendor ./cmd/jimmsrv +RUN go build -tags version -o jimmsrv -v ./cmd/jimmsrv # Define a smaller single process image for deployment FROM ${DOCKER_REGISTRY}ubuntu:20.04 AS deploy-env diff --git a/Makefile b/Makefile index 4542de647..ca5f2e072 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ PROJECT := github.com/canonical/jimm GIT_COMMIT := $(shell git rev-parse --verify HEAD) GIT_VERSION := $(shell git describe --abbrev=0 --dirty) +GO_VERSION := $(shell go list -f {{.GoVersion}} -m) ifeq ($(shell uname -p | sed -r 's/.*(x86|armel|armhf).*/golang/'), golang) GO_C := golang @@ -84,6 +85,7 @@ jimm-image: docker build --target deploy-env \ --build-arg="GIT_COMMIT=$(GIT_COMMIT)" \ --build-arg="VERSION=$(GIT_VERSION)" \ + --build-arg="GO_VERSION=$(GO_VERSION)" \ --tag jimm:latest . jimm-snap: diff --git a/charms/jimm-k8s/config.yaml b/charms/jimm-k8s/config.yaml index f057af49f..20d8241a5 100644 --- a/charms/jimm-k8s/config.yaml +++ b/charms/jimm-k8s/config.yaml @@ -86,3 +86,7 @@ options: description: | Duration for the JWT expiry (defaults to 5 minutes). default: 5m + macaroon-expiry-duration: + type: string + default: 24h + description: Expiry duration for authentication macaroons. diff --git a/charms/jimm-k8s/src/charm.py b/charms/jimm-k8s/src/charm.py index 4d3f19d19..566db985d 100755 --- a/charms/jimm-k8s/src/charm.py +++ b/charms/jimm-k8s/src/charm.py @@ -266,6 +266,7 @@ def _update_workload(self, event): "PRIVATE_KEY": self.config.get("private-key", ""), "PUBLIC_KEY": self.config.get("public-key", ""), "JIMM_JWT_EXPIRY": self.config.get("jwt-expiry", "5m"), + "JIMM_MACAROON_EXPIRY_DURATION": self.config.get("macaroon-expiry-duration", "24h"), } if self._state.dsn: config_values["JIMM_DSN"] = self._state.dsn diff --git a/charms/jimm-k8s/tests/unit/test_charm.py b/charms/jimm-k8s/tests/unit/test_charm.py index 288f19dc3..44306f157 100644 --- a/charms/jimm-k8s/tests/unit/test_charm.py +++ b/charms/jimm-k8s/tests/unit/test_charm.py @@ -36,6 +36,7 @@ "PRIVATE_KEY": "ly/dzsI9Nt/4JxUILQeAX79qZ4mygDiuYGqc2ZEiDEc=", "PUBLIC_KEY": "izcYsQy3TePp6bLjqOo3IRPFvkQd2IKtyODGqC6SdFk=", "JIMM_AUDIT_LOG_RETENTION_PERIOD_IN_DAYS": "0", + "JIMM_MACAROON_EXPIRY_DURATION": "24h", } diff --git a/charms/jimm/actions.yaml b/charms/jimm/actions.yaml index 2a2db2cec..941fd1561 100644 --- a/charms/jimm/actions.yaml +++ b/charms/jimm/actions.yaml @@ -1,3 +1,2 @@ # Copyright 2021 Canonical Ltd # See LICENSE file for licensing details. - diff --git a/charms/jimm/config.yaml b/charms/jimm/config.yaml index 7aae1a735..0f7c3e489 100644 --- a/charms/jimm/config.yaml +++ b/charms/jimm/config.yaml @@ -77,3 +77,7 @@ options: description: | Duration for the JWT expiry (defaults to 5 minutes). default: 5m + macaroon-expiry-duration: + type: string + default: 24h + description: Expiry duration for authentication macaroons. diff --git a/charms/jimm/src/charm.py b/charms/jimm/src/charm.py index d3c2fd830..39f86af98 100755 --- a/charms/jimm/src/charm.py +++ b/charms/jimm/src/charm.py @@ -134,6 +134,7 @@ def _on_config_changed(self, _): "private_key": self.config.get("private-key"), "audit_retention_period": self.config.get("audit-log-retention-period-in-days", ""), "jwt_expiry": self.config.get("jwt-expiry", "5m"), + "macaroon_expiry_duration": self.config.get("macaroon-expiry-duration"), } if self.config.get("postgres-secret-storage", False): diff --git a/charms/jimm/templates/jimm.env b/charms/jimm/templates/jimm.env index 945c9538f..59a0ae808 100644 --- a/charms/jimm/templates/jimm.env +++ b/charms/jimm/templates/jimm.env @@ -21,4 +21,5 @@ INSECURE_SECRET_STORAGE=enabled {% endif %} {%- if jwt_expiry %} JIMM_JWT_EXPIRY={{jwt_expiry}} -{% endif %} \ No newline at end of file +{% endif %} +JIMM_MACAROON_EXPIRY_DURATION={{macaroon_expiry_duration}} diff --git a/charms/jimm/tests/test_charm.py b/charms/jimm/tests/test_charm.py index 58eaa2b9c..861d269df 100644 --- a/charms/jimm/tests/test_charm.py +++ b/charms/jimm/tests/test_charm.py @@ -119,13 +119,14 @@ def test_config_changed(self): "private-key": "ly/dzsI9Nt/4JxUILQeAX79qZ4mygDiuYGqc2ZEiDEc=", "audit-log-retention-period-in-days": "10", "jwt-expiry": "10m", + "macaroon-expiry-duration": "48h", } ) self.assertTrue(os.path.exists(config_file)) with open(config_file) as f: lines = f.readlines() os.unlink(config_file) - self.assertEqual(len(lines), 19) + self.assertEqual(len(lines), 21) self.assertEqual(lines[0].strip(), "BAKERY_AGENT_FILE=") self.assertEqual(lines[1].strip(), "CANDID_URL=https://candid.example.com") self.assertEqual(lines[2].strip(), "JIMM_ADMINS=user1 user2 group1") @@ -152,6 +153,7 @@ def test_config_changed(self): lines[18].strip(), "JIMM_JWT_EXPIRY=10m", ) + self.assertEqual(lines[20].strip(), "JIMM_MACAROON_EXPIRY_DURATION=48h") def test_config_changed_redirect_to_dashboard(self): config_file = os.path.join(self.harness.charm.charm_dir, "juju-jimm.env") @@ -166,13 +168,14 @@ def test_config_changed_redirect_to_dashboard(self): "public-key": "izcYsQy3TePp6bLjqOo3IRPFvkQd2IKtyODGqC6SdFk=", "private-key": "ly/dzsI9Nt/4JxUILQeAX79qZ4mygDiuYGqc2ZEiDEc=", "audit-log-retention-period-in-days": "10", + "macaroon-expiry-duration": "48h", } ) self.assertTrue(os.path.exists(config_file)) with open(config_file) as f: lines = f.readlines() os.unlink(config_file) - self.assertEqual(len(lines), 19) + self.assertEqual(len(lines), 21) self.assertEqual(lines[0].strip(), "BAKERY_AGENT_FILE=") self.assertEqual(lines[1].strip(), "CANDID_URL=https://candid.example.com") self.assertEqual(lines[2].strip(), "JIMM_ADMINS=user1 user2 group1") @@ -199,6 +202,7 @@ def test_config_changed_redirect_to_dashboard(self): lines[18].strip(), "JIMM_JWT_EXPIRY=5m", ) + self.assertEqual(lines[20].strip(), "JIMM_MACAROON_EXPIRY_DURATION=48h") def test_config_changed_ready(self): config_file = os.path.join(self.harness.charm.charm_dir, "juju-jimm.env") @@ -212,13 +216,14 @@ def test_config_changed_ready(self): "public-key": "izcYsQy3TePp6bLjqOo3IRPFvkQd2IKtyODGqC6SdFk=", "private-key": "ly/dzsI9Nt/4JxUILQeAX79qZ4mygDiuYGqc2ZEiDEc=", "audit-log-retention-period-in-days": "10", + "macaroon-expiry-duration": "48h", } ) self.assertTrue(os.path.exists(config_file)) with open(config_file) as f: lines = f.readlines() os.unlink(config_file) - self.assertEqual(len(lines), 17) + self.assertEqual(len(lines), 19) self.assertEqual(lines[0].strip(), "BAKERY_AGENT_FILE=") self.assertEqual(lines[1].strip(), "CANDID_URL=https://candid.example.com") self.assertEqual(lines[2].strip(), "JIMM_ADMINS=user1 user2 group1") @@ -244,6 +249,7 @@ def test_config_changed_ready(self): lines[16].strip(), "JIMM_JWT_EXPIRY=5m", ) + self.assertEqual(lines[18].strip(), "JIMM_MACAROON_EXPIRY_DURATION=48h") def test_config_changed_with_agent(self): config_file = os.path.join(self.harness.charm.charm_dir, "juju-jimm.env") @@ -270,7 +276,7 @@ def test_config_changed_with_agent(self): with open(config_file) as f: lines = f.readlines() - self.assertEqual(len(lines), 17) + self.assertEqual(len(lines), 19) self.assertEqual( lines[0].strip(), "BAKERY_AGENT_FILE=" + self.harness.charm._agent_filename, @@ -280,6 +286,7 @@ def test_config_changed_with_agent(self): self.assertEqual(lines[4].strip(), "JIMM_DASHBOARD_LOCATION=https://jaas.ai/models") self.assertEqual(lines[7].strip(), "JIMM_LOG_LEVEL=info") self.assertEqual(lines[8].strip(), "JIMM_UUID=caaa4ba4-e2b5-40dd-9bf3-2bd26d6e17aa") + self.assertEqual(lines[18].strip(), "JIMM_MACAROON_EXPIRY_DURATION=24h") self.harness.charm._agent_filename = os.path.join(self.tempdir.name, "no-such-dir", "agent.json") self.harness.update_config( @@ -296,13 +303,14 @@ def test_config_changed_with_agent(self): ) with open(config_file) as f: lines = f.readlines() - self.assertEqual(len(lines), 17) + self.assertEqual(len(lines), 19) self.assertEqual(lines[0].strip(), "BAKERY_AGENT_FILE=") self.assertEqual(lines[1].strip(), "CANDID_URL=https://candid.example.com") self.assertEqual(lines[2].strip(), "JIMM_ADMINS=user1 user2 group1") self.assertEqual(lines[4].strip(), "JIMM_DASHBOARD_LOCATION=https://jaas.ai/models") self.assertEqual(lines[7].strip(), "JIMM_LOG_LEVEL=info") self.assertEqual(lines[8].strip(), "JIMM_UUID=caaa4ba4-e2b5-40dd-9bf3-2bd26d6e17aa") + self.assertEqual(lines[18].strip(), "JIMM_MACAROON_EXPIRY_DURATION=24h") def test_leader_elected(self): leader_file = os.path.join(self.harness.charm.charm_dir, "juju-jimm-leader.env") @@ -583,14 +591,14 @@ def test_insecure_secret_storage(self): with open(config_file) as f: lines = f.readlines() os.unlink(config_file) - self.assertEqual(len(lines), 19) + self.assertEqual(len(lines), 21) self.assertEqual(len([match for match in lines if "INSECURE_SECRET_STORAGE" in match]), 0) self.harness.update_config({"postgres-secret-storage": True}) self.assertTrue(os.path.exists(config_file)) with open(config_file) as f: lines = f.readlines() os.unlink(config_file) - self.assertEqual(len(lines), 21) + self.assertEqual(len(lines), 23) self.assertEqual(len([match for match in lines if "INSECURE_SECRET_STORAGE" in match]), 1) diff --git a/cmd/jimmsrv/main.go b/cmd/jimmsrv/main.go index 70721965c..90d0d4dfc 100644 --- a/cmd/jimmsrv/main.go +++ b/cmd/jimmsrv/main.go @@ -47,6 +47,15 @@ func start(ctx context.Context, s *service.Service) error { if addr == "" { addr = ":http-alt" } + macaroonExpiryDuration := 24 * time.Hour + durationString := os.Getenv("JIMM_MACAROON_EXPIRY_DURATION") + if durationString != "" { + expiry, err := time.ParseDuration(durationString) + if err != nil { + zapctx.Error(ctx, "failed to parse macaroon expiry duration", zap.Error(err)) + } + macaroonExpiryDuration = expiry + } jimmsvc, err := jimm.NewService(ctx, jimm.Params{ ControllerUUID: os.Getenv("JIMM_UUID"), DSN: os.Getenv("JIMM_DSN"), @@ -71,6 +80,7 @@ func start(ctx context.Context, s *service.Service) error { PrivateKey: os.Getenv("BAKERY_PRIVATE_KEY"), PublicKey: os.Getenv("BAKERY_PUBLIC_KEY"), AuditLogRetentionPeriodInDays: os.Getenv("JIMM_AUDIT_LOG_RETENTION_PERIOD_IN_DAYS"), + MacaroonExpiryDuration: macaroonExpiryDuration, }) if err != nil { return err diff --git a/internal/jimm/cloud.go b/internal/jimm/cloud.go index a63be7079..eab073b2e 100644 --- a/internal/jimm/cloud.go +++ b/internal/jimm/cloud.go @@ -528,11 +528,15 @@ func (j *JIMM) doCloudAdmin(ctx context.Context, u *openfga.User, ct names.Cloud // an unauthorized error. return errors.E(op, errors.CodeUnauthorized, "unauthorized") } - - if len(c.Regions) != 1 || len(c.Regions[0].Controllers) != 1 { - return errors.E(op, "cloud administration not available for %s", ct.Id()) + // Ensure we always have at least 1 region for the cloud with at least 1 controller + // managing that region. + if len(c.Regions) < 1 || len(c.Regions[0].Controllers) < 1 { + zapctx.Error(ctx, "number of regions available in cloud", zap.String("cloud", c.Name), zap.Int("regions", len(c.Regions))) + if len(c.Regions) > 0 { + zapctx.Error(ctx, "number of controllers available for cloud/region", zap.Int("controllers", len(c.Regions[0].Controllers))) + } + return errors.E(op, fmt.Sprintf("cloud administration not available for %s", ct.Id())) } - api, err := j.dial(ctx, &c.Regions[0].Controllers[0].Controller, names.ModelTag{}) if err != nil { return errors.E(op, err) diff --git a/internal/jimm/cloud_test.go b/internal/jimm/cloud_test.go index 47e5f427a..01f7f30a0 100644 --- a/internal/jimm/cloud_test.go +++ b/internal/jimm/cloud_test.go @@ -1235,6 +1235,7 @@ const grantCloudAccessTestEnv = `clouds: host-cloud-region: test-cloud/test-cloud-region regions: - name: default + - name: region2 users: - user: alice@external access: admin @@ -1250,6 +1251,9 @@ controllers: - cloud: test region: default priority: 1 + - cloud: test + region: region2 + priority: 1 ` var grantCloudAccessTests = []struct { @@ -1306,6 +1310,17 @@ var grantCloudAccessTests = []struct { }, Priority: 1, }}, + }, { + Name: "region2", + Controllers: []dbmodel.CloudRegionControllerPriority{{ + Controller: dbmodel.Controller{ + Name: "controller-1", + UUID: "00000001-0000-0000-0000-000000000001", + CloudName: "test-cloud", + CloudRegion: "test-cloud-region", + }, + Priority: 1, + }}, }}, Users: []dbmodel.UserCloudAccess{{ Username: "alice@external", diff --git a/service.go b/service.go index 89a6b2b09..1825a9429 100644 --- a/service.go +++ b/service.go @@ -151,6 +151,9 @@ type Params struct { // auditLogRetentionPeriodInDays is the number of days detailing how long // to keep an audit log for before purging it from the database. AuditLogRetentionPeriodInDays string + + // MacaroonExpiryDuration holds the expiry duration of authentication macaroons. + MacaroonExpiryDuration time.Duration } // A Service is the implementation of a JIMM server. @@ -442,11 +445,19 @@ func newAuthenticator(ctx context.Context, db *db.Database, client *openfga.OFGA if err != nil { return nil, err } + + if p.MacaroonExpiryDuration == 0 { + p.MacaroonExpiryDuration = 24 * time.Hour + } + return auth.JujuAuthenticator{ Bakery: identchecker.NewBakery(identchecker.BakeryParams{ - RootKeyStore: dbrootkeystore.NewRootKeys(100, nil).NewStore(db, dbrootkeystore.Policy{ - ExpiryDuration: 24 * time.Hour, - }), + RootKeyStore: dbrootkeystore.NewRootKeys(100, nil).NewStore( + db, + dbrootkeystore.Policy{ + ExpiryDuration: p.MacaroonExpiryDuration, + }, + ), Locator: httpbakery.NewThirdPartyLocator(nil, tps), Key: key, IdentityClient: candidClient, diff --git a/snaps/jimmctl/snapcraft.yaml b/snaps/jimmctl/snapcraft.yaml index bffba0181..019510f14 100644 --- a/snaps/jimmctl/snapcraft.yaml +++ b/snaps/jimmctl/snapcraft.yaml @@ -19,6 +19,7 @@ apps: - dot-local-share-juju - network + parts: jimmctl: plugin: go @@ -32,7 +33,7 @@ parts: files: plugin: dump - source: snaps/jimmctl/files + source: snaps/jimmctl/files/ source-type: local organize: jimmctl.wrapper: bin/wrappers/jimmctl