diff --git a/.air.toml b/.air.toml index bff0eb5de..5a6fb2c75 100644 --- a/.air.toml +++ b/.air.toml @@ -4,7 +4,6 @@ tmp_dir = "tmp" [build] args_bin = [] - bin = "env $(cat /vault/vault.env | xargs) ./tmp/jimm" cmd = "go build -gcflags='all=-N -l' -buildvcs=false -o ./tmp/jimm ./cmd/jimmsrv" delay = 1000 exclude_dir = [".vscode", "assets", "tmp", "vendor", "testdata"] @@ -12,7 +11,7 @@ tmp_dir = "tmp" exclude_regex = ["_test.go"] exclude_unchanged = false follow_symlink = false - full_bin = "env $(cat /vault/vault.env | xargs) dlv exec --accept-multiclient --log --headless --continue --listen :2345 --api-version 2 ./tmp/jimm" + full_bin = "dlv exec --accept-multiclient --log --headless --continue --listen :2345 --api-version 2 ./tmp/jimm" include_dir = [] include_ext = ["go", "tpl", "tmpl", "html"] kill_delay = "0s" diff --git a/.github/actions/test-server/README.md b/.github/actions/test-server/README.md new file mode 100644 index 000000000..2926efc38 --- /dev/null +++ b/.github/actions/test-server/README.md @@ -0,0 +1,28 @@ +# test-server +An action to create a JIMM server with real dependencies for integration test purposes. + +This action requires Docker to be installed to start JIMM and its related services. + +The action performs the following steps: +- Starts JIMM's docker compose test environment. +- Uses https://github.com/charmed-kubernetes/actions-operator action to start a Juju controller and connects it to JIMM. +- Ensures the local Juju CLI is setup to communicate with JIMM authenticating as a test user. + +Use the action by adding the following to a Github workflow: + +```yaml + integration-test: + runs-on: ubuntu-latest + name: Integration testing with JIMM + steps: + - name: Setup JIMM environment + uses: canonical/jimm@v3.1.7 + with: + jimm-version: "v3.1.7" + juju-channel: "3/stable" + ghcr-pat: ${{ secrets.GHCR_PAT }} +``` + +Note that it's recommended to pin the action version to the same version as `jimm-version` to ensure the action works as expected for that specific version of JIMM. + +For full details on the inputs see `action.yaml`. diff --git a/.github/actions/test-server/action.yaml b/.github/actions/test-server/action.yaml new file mode 100644 index 000000000..2d2933b36 --- /dev/null +++ b/.github/actions/test-server/action.yaml @@ -0,0 +1,117 @@ +name: JIMM Server Setup +description: "Create a JIMM environment" + +inputs: + jimm-version: + description: > + JIMM version tag to use. This will decide the version of JIMM to start e.g. v3.1.7 + A special tag of "dev" can be provided to use the current development version of JIMM. + required: true + juju-channel: + description: 'Juju snap channel to pass to charmed-kubernetes/actions-operator' + required: false + ghcr-pat: + description: > + PAT Token that has package:read access to canonical/JIMM + The PAT token can be left empty when building the development version of JIMM. + required: true + +outputs: + url: + description: 'URL where JIMM can be reached.' + value: "https://jimm.localhost" + client-id: + description: 'Test client ID to login to JIMM with a service account.' + value: "test-client-id" + client-secret: + description: 'Test client Secret to login to JIMM with a service account.' + value: "2M2blFbO4GX4zfggQpivQSxwWX1XGgNf" + ca-cert: + description: 'The CA certificate used to genereate the JIMM server cert.' + value: ${{ steps.fetch-cert.outputs.jimm-ca }} + +runs: + using: "composite" + steps: + - name: Login to GitHub Container Registry + if: ${{ inputs.jimm-version != 'dev' }} + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ inputs.ghcr-pat }} + + - name: Start server based on released version + if: ${{ inputs.jimm-version != 'dev' }} + run: make integration-test-env + shell: bash + env: + JIMM_VERSION: ${{ inputs.jimm-version }} + + - name: Start server based on development version + if: ${{ inputs.jimm-version == 'dev' }} + run: make dev-env + shell: bash + + - name: Retrieve server CA cert. + id: fetch-cert + run: | + echo 'jimm-ca<> $GITHUB_OUTPUT + cat ./local/traefik/certs/ca.crt >> $GITHUB_OUTPUT + echo 'EOF' >> $GITHUB_OUTPUT + shell: bash + + - name: Initialise LXD + run: | + sudo lxd waitready && \ + sudo lxd init --auto && \ + sudo chmod a+wr /var/snap/lxd/common/lxd/unix.socket && \ + lxc network set lxdbr0 ipv6.address none && \ + sudo usermod -a -G lxd $USER + shell: bash + + - name: Setup cloud-init script for bootstraping Juju controllers + run: ./local/jimm/setup-controller.sh + shell: bash + env: + SKIP_BOOTSTRAP: true + CLOUDINIT_FILE: "cloudinit.temp.yaml" + + - name: Setup Juju Controller + uses: charmed-kubernetes/actions-operator@main + with: + provider: "lxd" + channel: "5.19/stable" + juju-channel: ${{ inputs.juju-channel }} + bootstrap-options: "--config cloudinit.temp.yaml --config login-token-refresh-url=https://jimm.localhost/.well-known/jwks.json" + + # As described in https://github.com/charmed-kubernetes/actions-operator grab the newly setup controller name + - name: Save LXD controller name + id: lxd-controller + run: echo "name=$CONTROLLER_NAME" >> $GITHUB_OUTPUT + shell: bash + + - name: Install jimmctl, jaas plugin and yq + run: | + sudo snap install jimmctl --channel=3/stable && \ + sudo snap install jaas --channel=3/stable && + sudo snap install yq + shell: bash + + - name: Authenticate Juju CLI + run: chmod -R 666 ~/.local/share/juju/*.yaml && ./local/jimm/setup-cli-auth.sh + shell: bash + # Below is a hardcoded JWT using the same test-secret used in JIMM's docker compose and allows the CLI to authenticate as the jimm-test@canonical.com user. + env: + JWT: ZXlKMGVYQWlPaUpLVjFRaUxDSmhiR2NpT2lKSVV6STFOaUo5LmV5SnBjM01pT2lKUGJteHBibVVnU2xkVUlFSjFhV3hrWlhJaUxDSnBZWFFpT2pFM01qUXlNamcyTmpBc0ltVjRjQ0k2TXprMk5EYzFNelEyTUN3aVlYVmtJam9pYW1sdGJTSXNJbk4xWWlJNkltcHBiVzB0ZEdWemRFQmpZVzV2Ym1sallXd3VZMjl0SW4wLkpTWVhXcGF6T0FnX1VFZ2hkbjlOZkVQdWxhWWlJQVdaX3BuSmRDbnJvWEk= + + - name: Add LXD Juju controller to JIMM + run: ./local/jimm/add-controller.sh + shell: bash + env: + JIMM_CONTROLLER_NAME: "jimm" + CONTROLLER_NAME: ${{ steps.lxd-controller.outputs.name }} + + - name: Provide service account with cloud-credentials + run: ./local/jimm/setup-service-account.sh + shell: bash diff --git a/.github/workflows/cache.yaml b/.github/workflows/cache.yaml new file mode 100644 index 000000000..253df3cd7 --- /dev/null +++ b/.github/workflows/cache.yaml @@ -0,0 +1,32 @@ +name: Cache on default branch +on: + push: + branches: + - v3 + - "feature*" + +jobs: + go_cache: + name: Cache Go Dependencies and Build/Lint Artifacts + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-tags: true + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version-file: 'go.mod' + + - name: Build + run: go build ./... + + - name: Run Golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + args: --timeout 30m --verbose + version: v1.60 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e4ffac20e..0dc2ea76d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,9 +12,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - fetch-tags: true - fetch-depth: 0 - name: Setup Go uses: actions/setup-go@v4 @@ -27,15 +24,6 @@ jobs: - name: Install juju-db run: sudo snap install juju-db --channel 4.4/stable - - name: Add volume files - run: | - touch ./local/vault/approle.json - touch ./local/vault/roleid.txt - touch ./local/vault/vault.env - - - name: Create test certs - run: make certs - - name: Start test environment run: docker compose up -d --wait @@ -46,29 +34,3 @@ jobs: - name: Build and Test run: go test -mod readonly ./... -timeout 1h -cover - env: - JIMM_DSN: postgresql://jimm:jimm@localhost:5432/jimm - JIMM_TEST_PGXDSN: postgresql://jimm:jimm@localhost:5432/jimm - PGHOST: localhost - PGPASSWORD: jimm - PGSSLMODE: disable - PGUSER: jimm - PGPORT: 5432 - - smoke_test: - name: Smoke Test - runs-on: ubuntu-22.04 - # The docker compose has a healthcheck on the JIMM container. - # So if the compose returns with exit code 0 then the JIMM server successfully started. - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Add volume files - run: | - touch ./local/vault/approle.json - touch ./local/vault/roleid.txt - touch ./local/vault/vault.env - - - name: Run Smoke Test - run: docker compose --profile dev up -d --wait --timestamps diff --git a/.github/workflows/golangci-lint.yaml b/.github/workflows/golangci-lint.yaml new file mode 100644 index 000000000..3053f4ec0 --- /dev/null +++ b/.github/workflows/golangci-lint.yaml @@ -0,0 +1,27 @@ +name: golangci-lint +on: + pull_request: + +permissions: + contents: read + checks: write # Optional: allow write access to checks to allow the action to annotate code in the PR. + +jobs: + golangci: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Run Golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + args: --timeout 30m --verbose + version: v1.60 + diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml new file mode 100644 index 000000000..0e1e2a3e4 --- /dev/null +++ b/.github/workflows/integration-test.yaml @@ -0,0 +1,52 @@ +name: Integration Test + +on: + workflow_dispatch: + inputs: + jimm-version: + description: > + JIMM version tag to use. This will decide the version of JIMM to start e.g. v3.1.7. + View all available versions at https://github.com/canonical/jimm/pkgs/container/jimm. + required: true + pull_request: + +jobs: + startjimm: + name: Test JIMM with Juju controller + runs-on: ubuntu-22.04 + steps: + - name: Checkout JIMM repo + uses: actions/checkout@v4 + + - name: Setup Go + if: ${{ github.event_name == 'pull_request' }} + uses: actions/setup-go@v4 + with: + go-version-file: 'go.mod' + + - name: Go vendor to speed up docker build + if: ${{ github.event_name == 'pull_request' }} + run: go mod vendor + + - name: Start JIMM (pull request) + if: ${{ github.event_name == 'pull_request' }} + uses: ./.github/actions/test-server + with: + jimm-version: dev + juju-channel: "3/stable" + ghcr-pat: ${{ secrets.GITHUB_TOKEN }} + + - name: Start JIMM (manual run) + if: ${{ github.event_name == 'workflow_dispatch' }} + uses: ./.github/actions/test-server + with: + jimm-version: ${{ inputs.jimm-version }} + juju-channel: "3/stable" + ghcr-pat: ${{ secrets.GITHUB_TOKEN }} + + - name: Create a model, deploy an application and run juju status + run: | + juju add-model foo && \ + juju deploy haproxy && \ + sleep 5 && \ + juju status diff --git a/.github/workflows/update-sdk.yaml b/.github/workflows/update-sdk.yaml index 9152e826b..9c55dd050 100644 --- a/.github/workflows/update-sdk.yaml +++ b/.github/workflows/update-sdk.yaml @@ -31,7 +31,6 @@ jobs: repository: ${{ github.event.inputs.sdk-repo }} ref: ${{ github.event.inputs.sdk-version }} path: ./sdk - token: ${{ secrets.PAT }} - name: Setup Go uses: actions/setup-go@v5 @@ -46,11 +45,12 @@ jobs: SDK_VERSION: ${{ github.event.inputs.sdk-version }} run: | # Remove all in case some files are removed - rm -rf .[!.]* * + shopt -s nullglob + rm -rf .[!.git]* * cp -r $PROJECT/pkg/.[^.]* $PROJECT/pkg/* $PROJECT/go.mod . # Replace module references - find . -type f -exec sed -i "s|github.com/canonical/jimm/pkg|github.com/$SDK_REPO/$SDK_VERSION|" {} + + find . -type f -exec sed -i "s|github.com/canonical/jimm/v3/pkg|github.com/$SDK_REPO/$SDK_VERSION|" {} + sed -i "s|module .*|module github.com/$SDK_REPO/$SDK_VERSION|" go.mod # Needed to remove unused dependencies @@ -59,7 +59,7 @@ jobs: - name: Create Pull Request uses: peter-evans/create-pull-request@v6 with: - token: ${{ secrets.PAT }} + token: ${{ secrets.JIMM_GO_SDK_PAT }} path: ./sdk branch: update-sdk-${{ github.run_number }} title: Update SDK ${{ github.event.inputs.sdk-version }} diff --git a/.github/workflows/vulncheck.yaml b/.github/workflows/vulnerability-check.yaml similarity index 89% rename from .github/workflows/vulncheck.yaml rename to .github/workflows/vulnerability-check.yaml index 14c1a7d41..9deae64f4 100644 --- a/.github/workflows/vulncheck.yaml +++ b/.github/workflows/vulnerability-check.yaml @@ -1,4 +1,4 @@ -name: Security Check +name: Vulnerability Check on: schedule: @@ -16,5 +16,5 @@ jobs: uses: actions/setup-go@v5 with: go-version-file: go.mod - - name: Security checks + - name: Security scan uses: canonical/comsys-build-tools/.github/actions/security-scan@main diff --git a/.gitignore b/.gitignore index 18d156d4a..84d8a8902 100644 --- a/.gitignore +++ b/.gitignore @@ -11,10 +11,6 @@ /version/commit.txt /version/version.txt /tmp -/local/vault/approle.json -local/vault/approle.json -local/vault/roleid.txt -local/vault/vault.env *.crt *.key diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 000000000..bd973feb4 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,90 @@ +# Golangci-lint configuration. +# +# If a line has a comment, it means it has been changed from the default. +# This helps us understand what we're tweaking and why. + +run: + timeout: "5m" # Allow at least 5 minutes + issues-exit-code: 1 + tests: true + allow-parallel-runners: false + allow-serial-runners: false + # go: "1.23" + +issues: + exclude-use-default: true + exclude-case-sensitive: false + exclude-dirs-use-default: true + max-issues-per-linter: 50 + max-same-issues: 3 + new: false + fix: true + whole-files: false + +output: + print-issued-lines: true + print-linter-name: true + uniq-by-line: true + # path-prefix: # Not needed + show-stats: false + sort-results: true + +linters: + disable-all: true + enable: + # The following linters are enabled by default + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + + # The following linters are additional + + # Bug based linters + - gosec + - sqlclosecheck + - reassign + - nilerr + - durationcheck + - bodyclose + # - contextcheck # Issue right now + + # Style based linters + - promlinter + - gocritic + - gocognit + - goheader + - importas + - gci + +linters-settings: + gosec: + exclude-generated: false + severity: low + confidence: low + excludes: + - G601 # Implicit memory aliasing in for loop. Fixed in Go1.22+, as such exclude. + gocognit: + min-complexity: 30 + goheader: + template: |- + Copyright 2024 Canonical. + importas: + no-unaliased: false + no-extra-aliases: false + alias: + - pkg: github.com/juju/juju/rpc/params + alias: jujuparams + - pkg: github.com/canonical/jimm/v3/internal/openfga/names + alias: ofganames + - pkg: github.com/frankban/quicktest + alias: qt + gci: + skip-generated: true + custom-order: true + sections: + - standard + - default + - localmodule diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..78b2385ac --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "golang.go", + "babakks.vscode-go-test-suite" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..ea70deb9b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "go.lintTool": "golangci-lint", + "go.lintFlags": [ + "--fast" + ], + "go.lintOnSave": "workspace", +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..7c6a6c333 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,104 @@ +## Filing Bugs +File bugs at https://github.com/canonical/jimm/issues. + +## Testing +Many tests in JIMM require real services to be reachable i.e. Postgres, Vault, OpenFGA +and an IdP (Identity Provider). + +JIMM's docker compose file provides a convenient way of starting these services. + +### TLDR +Run: +``` +$ make test-env +$ go test ./... +``` + +### Pre-requisite +To check if your system has all the prequisites installed simply run `make sys-deps`. +This will check for all test prequisites and inform you how to install them if not installed. +You will need to install `make` first with `sudo apt install make` + +### Understanding the test suite +In order to enable testing with Juju's internal suites, it is required to have juju-db +(mongod) service installed. +This can be installed via: `sudo snap install juju-db --channel=4.4/stable`. + +Tests inside of `cmd/` and `internal/jujuapi/` are integration based, spinning up JIMM and +a Juju controller for testing. To spin up a Juju controller we use the `JujuConnSuite` which +in turn uses the [gocheck](http://labix.org/gocheck) test library. + +Because of the `JujuConnSuite` and its use in JIMM's test suites, there are 2 test libraries in JIMM: +- GoCheck based tests, identified in the function signature with `func Test(c *gc.C)`. + - These tests normally interact with a Juju controller. + - GoCheck should only be used when using the suites in `internal/jimmtest`. +- Stdlib `testing.T` tests, identified in the function signature with `func Test(t *testing.T)`. + - These tests vary in their scope but do not require a Juju controller. + - To provide assertions, the project uses [quicktest](https://github.com/frankban/quicktest), + a lean testing library. + +Because many tests rely on PostgreSQL, OpenFGA and Vault which are dockerised +you may simply run `make test-env` to be integration test ready. + +The above command won't start a dockerised instance of JIMM as tests are normally run locally. +Instead, to start a dockerised JIMM that will auto-reload on code changes, follow the instructions +in `local/README.md`. + +### Manual commands +If using VSCode, we recommend installing the +[go-test-suite](https://marketplace.visualstudio.com/items?itemName=babakks.vscode-go-test-suite) +extension to enable running these tests from the GUI as you would with normal Go tests and the Go +VSCode extension. + +Because [gocheck](http://labix.org/gocheck) does not parse the `go test -run` flags, the examples +below show how to run individual tests in a suite: +```bash +$ go test -check.f dialSuite.TestDialWithCredentialsStoredInVault` +$ go test -check.f MyTestSuite +$ go test -check.f "Test.*Works" +$ go test -check.f "MyTestSuite.Test.*Works" +``` + +For more verbose output, add `check.v` and `check.vv`. + +**Note:** The `check.f` command only applies to Go Check tests, any package with both Go Check tests +and normal `testing.T` tests will result in both sets of tests running. To avoid this look for where +Go Check registers its test suite into the Go test runner, normally in a file called `package_test.go` +and only run that test function. +E.g. in `internal/jujuapi` an example command to only run a single suite test would be: +``` +$ go test ./internal/jujuapi -check.f modelManagerSuite.TestListModelSummaries -run TestPackage ./internal/jujuapi +``` + +## Building/Publishing +Below are instructions on building the various binaries that are part of the project as well as +some information on how they are published. + +### jimmsrv +To build the JIMM server run `go build ./cmd/jimmsrv` + +The JIMM server is published as an OCI image using +[Rockcraft](https://documentation.ubuntu.com/rockcraft/en/latest/) +(a tool to create OCI images based on Ubuntu). + +Run `make rock` to pack the rock. The images are published to the Github repo's container registry +for later use by the JIMM-k8s charm. + +The JIMM server is also available as a snap and can be built with `make jimm-snap`. This snap is +not published to the snap store as it is intended to be used as part of a machine charm deployment. + +### jimmctl +To build jimmctl run `go build ./cmd/jimmctl` + +The jimmctl tool is published as a [Snap](https://snapcraft.io/jimmctl). + +Run `make jimmctl-snap` to build the snap. The snaps are published to the Snap Store +from where they can be conveniently installed. + +### jaas plugin +To build the jaas plugin run `go build ./cmd/jaas` + +The jaas plugin is published as a [Snap](https://snapcraft.io/jaas). + +Run `make jaas-snap` to build the snap. The snaps are published to the Snap Store +from where they can be conveniently installed. diff --git a/Makefile b/Makefile index 38cb491bc..4dd4e6646 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,10 @@ build: version/commit.txt version/version.txt build/server: version/commit.txt version/version.txt go build -tags version ./cmd/jimmsrv -check: version/commit.txt version/version.txt +lint: + golangci-lint run --timeout 5m + +check: version/commit.txt version/version.txt lint go test -timeout 30m $(PROJECT)/... -cover clean: @@ -32,23 +35,24 @@ clean: certs: @cd local/traefik/certs; ./certs.sh; cd - -test-env: sys-deps certs - @touch ./local/vault/approle.json && touch ./local/vault/roleid.txt && touch ./local/vault/vault.env +test-env: sys-deps @docker compose up --force-recreate -d --wait test-env-cleanup: @docker compose down -v --remove-orphans dev-env-setup: sys-deps certs - @touch ./local/vault/approle.json && touch ./local/vault/roleid.txt && touch ./local/vault/vault.env @make version/commit.txt && make version/version.txt dev-env: dev-env-setup - @docker compose --profile dev up --force-recreate + @docker compose --profile dev up -d --force-recreate --wait dev-env-cleanup: @docker compose --profile dev down -v --remove-orphans +integration-test-env: dev-env-setup + @JIMM_VERSION=$(JIMM_VERSION) docker compose --profile test up -d --force-recreate --wait + # Reformat all source files. format: gofmt -w -l . @@ -113,16 +117,25 @@ define check_dep fi endef -# Install packages required to develop JIMM and run tests. +# Install packages required to develop JIMM and/or run tests. APT_BASED := $(shell command -v apt-get >/dev/null; echo $$?) sys-deps: ifeq ($(APT_BASED),0) +# golangci-lint is necessary for linting. + @$(call check_dep,golangci-lint,Missing Golangci-lint - install from https://golangci-lint.run/welcome/install/) +# Go acts as the test runner. @$(call check_dep,go,Missing Go - install from https://go.dev/doc/install or 'sudo snap install go --classic') +# Git is useful to have. @$(call check_dep,git,Missing Git - install with 'sudo apt install git') +# GCC is required for the compilation process. @$(call check_dep,gcc,Missing gcc - install with 'sudo apt install build-essential') +# yq is necessary for some scripts that process controller-info yaml files. @$(call check_dep,yq,Missing yq - install with 'sudo snap install yq') - @$(call check_dep,gcc,Missing microk8s - install latest none-classic from snapstore') +# Microk8s is required if you want to start a Juju controller on Microk8s. + @$(call check_dep,microk8s,Missing microk8s - install with 'sudo snap install microk8s') +# Docker is required to start the test dependencies in containers. @$(call check_dep,docker,Missing Docker - install from https://docs.docker.com/engine/install/) +# juju-db is required for tests that use Juju's test fixture, requiring MongoDB. @$(call check_dep,juju-db.mongo,Missing juju-db - install with 'sudo snap install juju-db --channel=4.4/stable') else @echo sys-deps runs only on systems with apt-get @@ -139,7 +152,6 @@ help: @echo 'make sys-deps - Install the development environment system packages.' @echo 'make format - Format the source files.' @echo 'make simplify - Format and simplify the source files.' - @echo 'make get-local-auth - Get local auth to the API WSS endpoint locally.' @echo 'make rock - Build the JIMM rock.' @echo 'make load-rock - Load the most recently built rock into your local docker daemon.' diff --git a/README.md b/README.md index f7862742c..5c13a93eb 100644 --- a/README.md +++ b/README.md @@ -1,111 +1,73 @@ -# Juju Intelligent Model Manager +# JIMM - Juju Intelligent Model Manager -This service provides the ability to manage multiple juju models. It is -considered a work in progress. +[comment]: <> (Update the chat link below with a JIMM specific room) +

+ Chat | + Docs | + Charm +

-## Installation +JIMM is a Go based webserver used to provide extra functionality on top of Juju controllers. +If you are unfamiliar with Juju, we suggest visiting the [Juju docs](https://juju.is/) - +the open source orchestration engine for software operators. -To start using JIMM, first ensure you have a valid Go environment, -then run the following: +JIMM provides the ability to manage multiple Juju controllers from a single location with +enhanced enterprise functionality. - go get github.com/canonical/jimm +JIMM is the central component of JAAS (Juju As A Service), where JAAS is a set of services +acting together to enable storing state, storing secrets and auth. -## Go dependencies +## Features -The project uses Go modules (https://golang.org/cmd/go/#hdr-Module_maintenance) to manage Go -dependencies. **Note: Go 1.11 or greater needed.** +JIMM/JAAS provides enterprise level functionality layered on top of your Juju controller like: +- Federated login via an external identity provider using OAuth2.0 and OIDC. +- Fine grained access control with the ability to create user groups. +- A single gateway into your Juju estate. +- The ability to query for information across all your Juju controllers. -## JIMM versioning - -JIMM v0 and v1 follow a different versioning strategy than future releases. JIMM v0 was the initial release and used MongoDB to store state. -JIMM v1 was an upgrade that switched to using PostgreSQL for storing state but still retained similar functionality to v0. -These versions worked with Juju v2 and v3. - -Since a refresh of the project, there was an addition of delegated authorization in JIMM. This means that users are authenticated and authorized in JIMM before requests are forwarded to Juju. This work encompassed a breaking change and required changes in Juju (requiring a Juju controller of at least version 3.3). To better align JIMM with Juju it was decided to switch our versioning strategy to align with Juju. As a result of this, there is no JIMM v2 and instead from JIMM v3, the versioning strategy we follow is to match JIMM's to the Juju major versions we support. As an example, JIMM v3 can speak to Juju v3 controllers AND the last minor version of the previous major (Juju v2.9) for migration purposes. - -## Development environment +For a full overview of the capabilties, check out +[the docs](https://canonical-jaas-documentation.readthedocs-hosted.com/en/latest/explanation/jaas_overview/). -### Local: -A couple of system packages are required in order to set up a development -environment. To install them, run the following: -`make sys-deps` +## Dependencies -At this point, from the root of this branch, run the command: -`make install` +The project uses [Go modules](https://golang.org/cmd/go/#hdr-Module_maintenance) to manage +Go dependencies. **Note: Go 1.11 or greater needed.** -The command above builds and installs the JIMM binaries, and places -them in `$GOPATH/bin`. This is the list of the installed commands: - -- jemd: start the JIMM server; -- jaas-admin: perform admin commands on JIMM; - -### Docker compose: -See [here](./local/README.md) on how to get started. - -## Testing +A brief explanation of the various services that JIMM depends on is below: +- [Vault](https://www.vaultproject.io/): User cloud-credentials and private keys are stored in Vault. Cloud-credentials are API keys that +enable Juju to communicate with a cloud's API. +- [PostgreSQL](https://www.postgresql.org/): All non-sensitive state is stored in Postgres. +- [OpenFGA](https://openfga.dev/): A distributed authorisation server where authorisation rules are stored and queried +using relation based access control. +- IdP: An identity provider which supports OAuth2.0 and OIDC. -## TLDR -Run: -``` -$ make test-env -$ go test ./... -``` -### Pre-requisite -To check if your system has all the prequisites installed simply run `make sys-deps`. -This will check for all test prequisites and inform you how to install them if not installed. -You will need to install `make` first with `sudo apt install make` - -### Understanding the test suite -As the juju controller internal suites start their our mongod instances, it is required to have juju-db (mongod). -This can be installed via: `sudo snap install juju-db`. -The latest JIMM has an upgraded dependency on Juju which requires in turn requires juju-db from channel `4.4/stable`, - this can be installed with `sudo snap install juju-db --channel=4.4/stable` - -Tests inside of `cmd/` create a JIMM server and test the jimmctl and jaas CLI packages. The Juju CLI requires that it connects to -an HTTPS server, but these tests also start a Juju controller which expects to be able to fetch a JWKS and macaroon publickey -from JIMM (which is running as an HTTPS server). This would normally result in a TLS certificate error, however JIMM will -attempt to use a custom self-signed cert from the certificate generated in `local/traefik/certs`. The make command `make certs` will generate these certs and place the CA in your system's cert pool which will be picked up by the Go HTTP client. - -The rest of the suite relies on PostgreSQL, OpenFGA and Hashicorp Vault which are dockerised -and as such you may simple run `make test-env` to be integration test ready. -The above command won't start a dockerised instance of JIMM as tests are normally run locally. Instead, to start a -dockerised JIMM that will auto-reload on code changes, follow the instructions in `local/README.md`. - -### Manual commands -The tests utilise [go.check](http://labix.org/gocheck) for suites and you may run tests individually like so: -```bash -$ go test -check.f dialSuite.TestDialWithCredentialsStoredInVault` -$ go test -check.f MyTestSuite -$ go test -check.f "Test.*Works" -$ go test -check.f "MyTestSuite.Test.*Works" -``` - -For more verbose output, use `-check.v` and `-check.vv` +## JIMM versioning +The versioning strategy we follow is to match JIMM's major version to the corresponding +Juju major version we support. -### Make -Run `make check` to test the application. -Run `make help` to display help about all the available make targets. +Additionally JIMM will also support Juju's last minor version of the previous major to +support model migrations. -## Local QA +E.g. JIMM v3 supports Juju v3 controllers AND the last minor version +of the previous major, v2.9. -To start a local server for QA purposes do the following: +For more information on JIMM's history and previous version strategy see [here](./doc/versioning.md). - sudo cp tools/jimmqa.crt /usr/local/share/ca-certificates - sudo update-ca-certificates - make server +## Binaries -This will start JIMM server running on localhost:8082 which is configured -to use https://api.staging.jujucharms.com/identity as its identity -provider. +This repository contains 3 binaries: +- jimmsrv: The JIMM server. +- jimmctl: A CLI tool for administrators of JIMM to view audit logs, manage permissions, etc. +Available as a snap. +- jaas: A plugin for the Juju CLI, extend the base set of command with extra functionality when +communicating with a JAAS environment. -To add the new JIMM to your juju environment use the command: +## Development environment - juju login localhost:8082 -c local-jaas +See [here](./local/README.md) on how to get started. -To bootstrap a new controller and add it to the local JIMM use the -following commands: +## Testing - juju bootstrap --config identity-url=https://api.staging.jujucharms.com/identity --config allow-model-access=true / - jaas-admin --jimm-url https://localhost:8082 add-controller / +See [here](./CONTRIBUTING.md) on how to get started. diff --git a/cmd/jaas/cmd/addserviceaccount.go b/cmd/jaas/cmd/addserviceaccount.go index 2e14cb956..5584ec966 100644 --- a/cmd/jaas/cmd/addserviceaccount.go +++ b/cmd/jaas/cmd/addserviceaccount.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package cmd diff --git a/cmd/jaas/cmd/addserviceaccount_test.go b/cmd/jaas/cmd/addserviceaccount_test.go index 6a6596072..fa6966171 100644 --- a/cmd/jaas/cmd/addserviceaccount_test.go +++ b/cmd/jaas/cmd/addserviceaccount_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test diff --git a/cmd/jaas/cmd/export_test.go b/cmd/jaas/cmd/export_test.go index ac0c8489f..76bee863c 100644 --- a/cmd/jaas/cmd/export_test.go +++ b/cmd/jaas/cmd/export_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package cmd @@ -7,15 +7,14 @@ import ( jujuapi "github.com/juju/juju/api" "github.com/juju/juju/cmd/modelcmd" "github.com/juju/juju/jujuclient" + + "github.com/canonical/jimm/v3/internal/cmdtest" ) func NewAddServiceAccountCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &addServiceAccountCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -23,11 +22,8 @@ func NewAddServiceAccountCommandForTesting(store jujuclient.ClientStore, lp juju func NewListServiceAccountCredentialsCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &listServiceAccountCredentialsCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -35,11 +31,8 @@ func NewListServiceAccountCredentialsCommandForTesting(store jujuclient.ClientSt func NewUpdateCredentialsCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &updateCredentialCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -47,11 +40,8 @@ func NewUpdateCredentialsCommandForTesting(store jujuclient.ClientStore, lp juju func NewGrantCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &grantCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) diff --git a/cmd/jaas/cmd/grant.go b/cmd/jaas/cmd/grant.go index d935042ff..0930f7f40 100644 --- a/cmd/jaas/cmd/grant.go +++ b/cmd/jaas/cmd/grant.go @@ -1,11 +1,10 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package cmd import ( - "fmt" - "github.com/juju/cmd/v3" + "github.com/juju/gnuflag" jujuapi "github.com/juju/juju/api" jujucmd "github.com/juju/juju/cmd" "github.com/juju/juju/cmd/modelcmd" @@ -57,6 +56,14 @@ func (c *grantCommand) Info() *cmd.Info { }) } +// Init implements the cmd.Command interface. +func (c *grantCommand) SetFlags(f *gnuflag.FlagSet) { + c.CommandBase.SetFlags(f) + c.out.AddFlags(f, "smart", map[string]cmd.Formatter{ + "smart": cmd.FormatSmart, + }) +} + // Init implements the cmd.Command interface. func (c *grantCommand) Init(args []string) error { if len(args) < 1 { @@ -92,6 +99,11 @@ func (c *grantCommand) Run(ctxt *cmd.Context) error { if err != nil { return errors.E(err) } - fmt.Fprintln(ctxt.Stdout, "access granted") + err = c.out.Write(ctxt, "access granted") + if err != nil { + return errors.E(err) + } + + // fmt.Fprintf(ctxt.Stdout, "access granted") return nil } diff --git a/cmd/jaas/cmd/grant_test.go b/cmd/jaas/cmd/grant_test.go index efd727bdd..8acc174ef 100644 --- a/cmd/jaas/cmd/grant_test.go +++ b/cmd/jaas/cmd/grant_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test diff --git a/cmd/jaas/cmd/listserviceaccountcredentials.go b/cmd/jaas/cmd/listserviceaccountcredentials.go index 0688ef8ad..002a8fa87 100644 --- a/cmd/jaas/cmd/listserviceaccountcredentials.go +++ b/cmd/jaas/cmd/listserviceaccountcredentials.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd @@ -31,6 +31,7 @@ This command only shows credentials uploaded to the controller that belong to th Client-side credentials should be managed via the juju credentials command. ` + //nolint:gosec // Believes credentials are exposed but aren't. listServiceAccountCredentialsExamples = ` juju list-service-account-credentials juju list-service-account-credentials --show-secrets diff --git a/cmd/jaas/cmd/listserviceaccountcredentials_test.go b/cmd/jaas/cmd/listserviceaccountcredentials_test.go index 8fdeb3ec9..f8f6bcd4c 100644 --- a/cmd/jaas/cmd/listserviceaccountcredentials_test.go +++ b/cmd/jaas/cmd/listserviceaccountcredentials_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test diff --git a/cmd/jaas/cmd/package_test.go b/cmd/jaas/cmd/package_test.go index 1a5f86c31..4addb4e34 100644 --- a/cmd/jaas/cmd/package_test.go +++ b/cmd/jaas/cmd/package_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test diff --git a/cmd/jaas/cmd/updatecredentials.go b/cmd/jaas/cmd/updatecredentials.go index 6fb9c8918..28303d982 100644 --- a/cmd/jaas/cmd/updatecredentials.go +++ b/cmd/jaas/cmd/updatecredentials.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package cmd @@ -11,10 +11,9 @@ import ( jujucmd "github.com/juju/juju/cmd" "github.com/juju/juju/cmd/modelcmd" "github.com/juju/juju/jujuclient" - "github.com/juju/names/v5" - "github.com/juju/juju/rpc/params" jujuparams "github.com/juju/juju/rpc/params" + "github.com/juju/names/v5" "github.com/canonical/jimm/v3/internal/errors" "github.com/canonical/jimm/v3/pkg/api" diff --git a/cmd/jaas/cmd/updatecredentials_test.go b/cmd/jaas/cmd/updatecredentials_test.go index 700d60694..5242311d6 100644 --- a/cmd/jaas/cmd/updatecredentials_test.go +++ b/cmd/jaas/cmd/updatecredentials_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test @@ -7,6 +7,8 @@ import ( "fmt" "github.com/juju/cmd/v3/cmdtesting" + jujucloud "github.com/juju/juju/cloud" + "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" gc "gopkg.in/check.v1" @@ -18,8 +20,6 @@ import ( "github.com/canonical/jimm/v3/internal/openfga" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" jimmnames "github.com/canonical/jimm/v3/pkg/names" - jujucloud "github.com/juju/juju/cloud" - "github.com/juju/juju/rpc/params" ) type updateCredentialsSuite struct { diff --git a/cmd/jaas/main.go b/cmd/jaas/main.go index cc95c87af..22dc0b9a0 100644 --- a/cmd/jaas/main.go +++ b/cmd/jaas/main.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package main @@ -7,8 +7,9 @@ import ( "os" "strings" - "github.com/canonical/jimm/v3/cmd/jaas/cmd" jujucmd "github.com/juju/cmd/v3" + + "github.com/canonical/jimm/v3/cmd/jaas/cmd" ) var jaasDoc = ` diff --git a/cmd/jimmctl/cmd/addcloudtocontroller.go b/cmd/jimmctl/cmd/addcloudtocontroller.go index 2a2da2356..aa4b88c62 100644 --- a/cmd/jimmctl/cmd/addcloudtocontroller.go +++ b/cmd/jimmctl/cmd/addcloudtocontroller.go @@ -1,10 +1,10 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd import ( "fmt" - "io/ioutil" + "os" "github.com/juju/cmd/v3" "github.com/juju/gnuflag" @@ -194,7 +194,7 @@ type cloudToCommandAdapter struct { // ReadCloudData implements CloudMetadataStore.ReadCloudData. func (cloudToCommandAdapter) ReadCloudData(path string) ([]byte, error) { - return ioutil.ReadFile(path) + return os.ReadFile(path) } // ParseOneCloud implements CloudMetadataStore.ParseOneCloud. diff --git a/cmd/jimmctl/cmd/addcloudtocontroller_test.go b/cmd/jimmctl/cmd/addcloudtocontroller_test.go index eb610594b..2cbcf37ed 100644 --- a/cmd/jimmctl/cmd/addcloudtocontroller_test.go +++ b/cmd/jimmctl/cmd/addcloudtocontroller_test.go @@ -1,9 +1,8 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test import ( "context" - "io/ioutil" "os" "path/filepath" "strconv" @@ -187,7 +186,8 @@ clouds: err = s.JIMM.Database.GetCloud(context.Background(), &cloud) c.Assert(err, gc.IsNil) controller := dbmodel.Controller{Name: "controller-1"} - s.JIMM.Database.GetController(context.Background(), &controller) + err = s.JIMM.Database.GetController(context.Background(), &controller) + c.Assert(err, gc.IsNil) c.Assert(controller.CloudRegions[test.expectedIndex].CloudRegion.CloudName, gc.Equals, test.expectedCloudName) } cleanupFunc() @@ -197,11 +197,12 @@ clouds: } func writeTempFile(c *gc.C, content string) (string, func()) { - dir, err := ioutil.TempDir("", "add-cloud-to-controller-test") + dir, err := os.MkdirTemp("", "add-cloud-to-controller-test") c.Assert(err, gc.Equals, nil) tmpfn := filepath.Join(dir, "tmp.yaml") - err = ioutil.WriteFile(tmpfn, []byte(content), 0666) + + err = os.WriteFile(tmpfn, []byte(content), 0600) c.Assert(err, gc.Equals, nil) return tmpfn, func() { os.RemoveAll(dir) diff --git a/cmd/jimmctl/cmd/addcontroller.go b/cmd/jimmctl/cmd/addcontroller.go index 90268d5ec..bd1e7fe52 100644 --- a/cmd/jimmctl/cmd/addcontroller.go +++ b/cmd/jimmctl/cmd/addcontroller.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd diff --git a/cmd/jimmctl/cmd/addcontroller_test.go b/cmd/jimmctl/cmd/addcontroller_test.go index 1d5e921ff..00e9d0162 100644 --- a/cmd/jimmctl/cmd/addcontroller_test.go +++ b/cmd/jimmctl/cmd/addcontroller_test.go @@ -1,10 +1,9 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test import ( "context" - "io/ioutil" "os" "path/filepath" @@ -110,11 +109,11 @@ func writeYAMLTempFile(c *gc.C, payload interface{}) (string, string) { data, err := yaml.Marshal(payload) c.Assert(err, gc.Equals, nil) - dir, err := ioutil.TempDir("", "add-controller-test") + dir, err := os.MkdirTemp("", "add-controller-test") c.Assert(err, gc.Equals, nil) tmpfn := filepath.Join(dir, "tmp.yaml") - err = ioutil.WriteFile(tmpfn, data, 0666) + err = os.WriteFile(tmpfn, data, 0600) c.Assert(err, gc.Equals, nil) return dir, tmpfn } diff --git a/cmd/jimmctl/cmd/auth.go b/cmd/jimmctl/cmd/auth.go index ff9e3778e..346d67c46 100644 --- a/cmd/jimmctl/cmd/auth.go +++ b/cmd/jimmctl/cmd/auth.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package cmd diff --git a/cmd/jimmctl/cmd/controllerinfo.go b/cmd/jimmctl/cmd/controllerinfo.go index b5de52ac4..a803c6c11 100644 --- a/cmd/jimmctl/cmd/controllerinfo.go +++ b/cmd/jimmctl/cmd/controllerinfo.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd @@ -123,7 +123,7 @@ func (c *controllerInfoCommand) Run(ctxt *cmd.Context) error { if err != nil { return errors.Mask(err) } - err = os.WriteFile(c.file.Path, data, 0666) + err = os.WriteFile(c.file.Path, data, 0600) if err != nil { return errors.Mask(err) } diff --git a/cmd/jimmctl/cmd/controllerinfo_test.go b/cmd/jimmctl/cmd/controllerinfo_test.go index 48f2b5a1e..dd8c37798 100644 --- a/cmd/jimmctl/cmd/controllerinfo_test.go +++ b/cmd/jimmctl/cmd/controllerinfo_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test diff --git a/cmd/jimmctl/cmd/crossmodelquery.go b/cmd/jimmctl/cmd/crossmodelquery.go index 06f98e99b..296154342 100644 --- a/cmd/jimmctl/cmd/crossmodelquery.go +++ b/cmd/jimmctl/cmd/crossmodelquery.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package cmd diff --git a/cmd/jimmctl/cmd/crossmodelquery_test.go b/cmd/jimmctl/cmd/crossmodelquery_test.go index df378d3bb..4521f8075 100644 --- a/cmd/jimmctl/cmd/crossmodelquery_test.go +++ b/cmd/jimmctl/cmd/crossmodelquery_test.go @@ -1,18 +1,19 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test import ( "encoding/json" - "github.com/canonical/jimm/v3/cmd/jimmctl/cmd" - "github.com/canonical/jimm/v3/internal/cmdtest" - "github.com/canonical/jimm/v3/internal/jimmtest" "github.com/juju/cmd/v3/cmdtesting" jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/juju/testing/factory" "github.com/juju/names/v5" gc "gopkg.in/check.v1" + + "github.com/canonical/jimm/v3/cmd/jimmctl/cmd" + "github.com/canonical/jimm/v3/internal/cmdtest" + "github.com/canonical/jimm/v3/internal/jimmtest" ) type crossModelQuerySuite struct { diff --git a/cmd/jimmctl/cmd/export_test.go b/cmd/jimmctl/cmd/export_test.go index e32b9ff94..3f06e1802 100644 --- a/cmd/jimmctl/cmd/export_test.go +++ b/cmd/jimmctl/cmd/export_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd @@ -8,6 +8,8 @@ import ( "github.com/juju/juju/cloud" "github.com/juju/juju/cmd/modelcmd" "github.com/juju/juju/jujuclient" + + "github.com/canonical/jimm/v3/internal/cmdtest" ) var ( @@ -22,11 +24,8 @@ type AccessResult = accessResult func NewListControllersCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &listControllersCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -34,11 +33,8 @@ func NewListControllersCommandForTesting(store jujuclient.ClientStore, lp jujuap func NewModelStatusCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &modelStatusCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -46,11 +42,8 @@ func NewModelStatusCommandForTesting(store jujuclient.ClientStore, lp jujuapi.Lo func NewGrantAuditLogAccessCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &grantAuditLogAccessCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -58,11 +51,8 @@ func NewGrantAuditLogAccessCommandForTesting(store jujuclient.ClientStore, lp ju func NewRevokeAuditLogAccessCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &revokeAuditLogAccessCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -70,11 +60,8 @@ func NewRevokeAuditLogAccessCommandForTesting(store jujuclient.ClientStore, lp j func NewListAuditEventsCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &listAuditEventsCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -84,10 +71,7 @@ func NewAddCloudToControllerCommandForTesting(store jujuclient.ClientStore, lp j cmd := &addCloudToControllerCommand{ store: store, cloudByNameFunc: cloudByNameFunc, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -97,11 +81,8 @@ type RemoveCloudFromControllerAPI = removeCloudFromControllerAPI func NewRemoveCloudFromControllerCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider, removeCloudFromControllerAPIFunc func() (RemoveCloudFromControllerAPI, error)) cmd.Command { cmd := &removeCloudFromControllerCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), removeCloudFromControllerAPIFunc: removeCloudFromControllerAPIFunc, } if removeCloudFromControllerAPIFunc == nil { @@ -113,11 +94,8 @@ func NewRemoveCloudFromControllerCommandForTesting(store jujuclient.ClientStore, func NewAddControllerCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &addControllerCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -125,11 +103,8 @@ func NewAddControllerCommandForTesting(store jujuclient.ClientStore, lp jujuapi. func NewRemoveControllerCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &removeControllerCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -145,11 +120,8 @@ func NewControllerInfoCommandForTesting(store jujuclient.ClientStore) cmd.Comman func NewSetControllerDeprecatedCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &setControllerDeprecatedCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -157,11 +129,8 @@ func NewSetControllerDeprecatedCommandForTesting(store jujuclient.ClientStore, l func NewImportModelCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &importModelCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -169,11 +138,8 @@ func NewImportModelCommandForTesting(store jujuclient.ClientStore, lp jujuapi.Lo func NewUpdateMigratedModelCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &updateMigratedModelCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -181,11 +147,8 @@ func NewUpdateMigratedModelCommandForTesting(store jujuclient.ClientStore, lp ju func NewImportCloudCredentialsCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &importCloudCredentialsCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -193,11 +156,8 @@ func NewImportCloudCredentialsCommandForTesting(store jujuclient.ClientStore, lp func NewAddGroupCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &addGroupCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -205,11 +165,8 @@ func NewAddGroupCommandForTesting(store jujuclient.ClientStore, lp jujuapi.Login func NewRenameGroupCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &renameGroupCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -217,11 +174,8 @@ func NewRenameGroupCommandForTesting(store jujuclient.ClientStore, lp jujuapi.Lo func NewRemoveGroupCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &removeGroupCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -229,11 +183,8 @@ func NewRemoveGroupCommandForTesting(store jujuclient.ClientStore, lp jujuapi.Lo func NewListGroupsCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &listGroupsCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -241,11 +192,8 @@ func NewListGroupsCommandForTesting(store jujuclient.ClientStore, lp jujuapi.Log func NewAddRelationCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &addRelationCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -253,11 +201,8 @@ func NewAddRelationCommandForTesting(store jujuclient.ClientStore, lp jujuapi.Lo func NewRemoveRelationCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &removeRelationCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -265,11 +210,8 @@ func NewRemoveRelationCommandForTesting(store jujuclient.ClientStore, lp jujuapi func NewListRelationsCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &listRelationsCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -277,11 +219,8 @@ func NewListRelationsCommandForTesting(store jujuclient.ClientStore, lp jujuapi. func NewCheckRelationCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &checkRelationCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -289,11 +228,8 @@ func NewCheckRelationCommandForTesting(store jujuclient.ClientStore, lp jujuapi. func NewCrossModelQueryCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &crossModelQueryCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -301,11 +237,8 @@ func NewCrossModelQueryCommandForTesting(store jujuclient.ClientStore, lp jujuap func NewPurgeLogsCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &purgeLogsCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) @@ -313,11 +246,8 @@ func NewPurgeLogsCommandForTesting(store jujuclient.ClientStore, lp jujuapi.Logi func NewMigrateModelCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command { cmd := &migrateModelCommand{ - store: store, - dialOpts: &jujuapi.DialOpts{ - InsecureSkipVerify: true, - LoginProvider: lp, - }, + store: store, + dialOpts: cmdtest.TestDialOpts(lp), } return modelcmd.WrapBase(cmd) diff --git a/cmd/jimmctl/cmd/grantauditlogaccess.go b/cmd/jimmctl/cmd/grantauditlogaccess.go index b3e9140b0..77e3e17da 100644 --- a/cmd/jimmctl/cmd/grantauditlogaccess.go +++ b/cmd/jimmctl/cmd/grantauditlogaccess.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd @@ -58,7 +58,7 @@ func (c *grantAuditLogAccessCommand) SetFlags(f *gnuflag.FlagSet) { // Init implements the cmd.Command interface. func (c *grantAuditLogAccessCommand) Init(args []string) error { - if len(args) < 0 { + if len(args) == 0 { return errors.E("missing username") } c.username, args = args[0], args[1:] diff --git a/cmd/jimmctl/cmd/grantauditlogaccess_test.go b/cmd/jimmctl/cmd/grantauditlogaccess_test.go index 3d45221a5..f3d54130b 100644 --- a/cmd/jimmctl/cmd/grantauditlogaccess_test.go +++ b/cmd/jimmctl/cmd/grantauditlogaccess_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test @@ -15,8 +15,7 @@ type grantAuditLogAccessSuite struct { cmdtest.JimmCmdSuite } -// TODO (alesstimec) uncomment once granting/revoking is reimplemented -//var _ = gc.Suite(&grantAuditLogAccessSuite{}) +var _ = gc.Suite(&grantAuditLogAccessSuite{}) func (s *grantAuditLogAccessSuite) TestGrantAuditLogAccessSuperuser(c *gc.C) { // alice is superuser diff --git a/cmd/jimmctl/cmd/group.go b/cmd/jimmctl/cmd/group.go index c7dbfb54b..74e8b8aee 100644 --- a/cmd/jimmctl/cmd/group.go +++ b/cmd/jimmctl/cmd/group.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd @@ -159,7 +159,6 @@ func newRenameGroupCommand() cmd.Command { // renameGroupCommand renames a group. type renameGroupCommand struct { modelcmd.ControllerCommandBase - out cmd.Output store jujuclient.ClientStore dialOpts *jujuapi.DialOpts @@ -284,7 +283,7 @@ func (c *removeGroupCommand) Run(ctxt *cmd.Context) error { if err != nil { return errors.E(err, "Failed to read from input.") } - text = strings.Replace(text, "\n", "", -1) + text = strings.ReplaceAll(text, "\n", "") if !(text == "y" || text == "Y") { return nil } @@ -325,7 +324,6 @@ type listGroupsCommand struct { store jujuclient.ClientStore dialOpts *jujuapi.DialOpts - name string limit int offset int } diff --git a/cmd/jimmctl/cmd/group_test.go b/cmd/jimmctl/cmd/group_test.go index e50756310..d9ff09db5 100644 --- a/cmd/jimmctl/cmd/group_test.go +++ b/cmd/jimmctl/cmd/group_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test diff --git a/cmd/jimmctl/cmd/importcloudcredentials.go b/cmd/jimmctl/cmd/importcloudcredentials.go index 3d9d32b52..9a24aafa5 100644 --- a/cmd/jimmctl/cmd/importcloudcredentials.go +++ b/cmd/jimmctl/cmd/importcloudcredentials.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd @@ -17,6 +17,7 @@ import ( "github.com/canonical/jimm/v3/internal/errors" ) +//nolint:gosec // Thinks a credential is exposed. const importCloudCredentialsDoc = ` import-cloud-credentials imports a set of cloud credentials loaded from a file containing a series of JSON objects. The JSON diff --git a/cmd/jimmctl/cmd/importcloudcredentials_test.go b/cmd/jimmctl/cmd/importcloudcredentials_test.go index 718ffd982..ee0c7cd80 100644 --- a/cmd/jimmctl/cmd/importcloudcredentials_test.go +++ b/cmd/jimmctl/cmd/importcloudcredentials_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test @@ -22,6 +22,7 @@ type importCloudCredentialsSuite struct { var _ = gc.Suite(&importCloudCredentialsSuite{}) +//nolint:gosec // Thinks hardcoded creds. const creds = `{ "_id": "aws/alice@canonical.com/test1", "type": "access-key", @@ -60,7 +61,7 @@ func (s *importCloudCredentialsSuite) TestImportCloudCredentials(c *gc.C) { c.Assert(err, gc.IsNil) tmpfile := filepath.Join(c.MkDir(), "test.json") - err = os.WriteFile(tmpfile, []byte(creds), 0660) + err = os.WriteFile(tmpfile, []byte(creds), 0600) c.Assert(err, gc.IsNil) // alice is superuser diff --git a/cmd/jimmctl/cmd/importmodel.go b/cmd/jimmctl/cmd/importmodel.go index ab363358c..520941921 100644 --- a/cmd/jimmctl/cmd/importmodel.go +++ b/cmd/jimmctl/cmd/importmodel.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd diff --git a/cmd/jimmctl/cmd/importmodel_test.go b/cmd/jimmctl/cmd/importmodel_test.go index 6b225857f..81065aa65 100644 --- a/cmd/jimmctl/cmd/importmodel_test.go +++ b/cmd/jimmctl/cmd/importmodel_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test diff --git a/cmd/jimmctl/cmd/listauditevents.go b/cmd/jimmctl/cmd/listauditevents.go index 3512d3bf6..fe7e66675 100644 --- a/cmd/jimmctl/cmd/listauditevents.go +++ b/cmd/jimmctl/cmd/listauditevents.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd diff --git a/cmd/jimmctl/cmd/listauditevents_test.go b/cmd/jimmctl/cmd/listauditevents_test.go index 6dc1c438c..d837431f2 100644 --- a/cmd/jimmctl/cmd/listauditevents_test.go +++ b/cmd/jimmctl/cmd/listauditevents_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test diff --git a/cmd/jimmctl/cmd/listcontrollers.go b/cmd/jimmctl/cmd/listcontrollers.go index 031026d1f..b5bb2f5aa 100644 --- a/cmd/jimmctl/cmd/listcontrollers.go +++ b/cmd/jimmctl/cmd/listcontrollers.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd diff --git a/cmd/jimmctl/cmd/listcontrollers_test.go b/cmd/jimmctl/cmd/listcontrollers_test.go index d586c4e46..94c0cd707 100644 --- a/cmd/jimmctl/cmd/listcontrollers_test.go +++ b/cmd/jimmctl/cmd/listcontrollers_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test diff --git a/cmd/jimmctl/cmd/migratemodel.go b/cmd/jimmctl/cmd/migratemodel.go index 815d6cce4..875407947 100644 --- a/cmd/jimmctl/cmd/migratemodel.go +++ b/cmd/jimmctl/cmd/migratemodel.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package cmd diff --git a/cmd/jimmctl/cmd/migratemodel_test.go b/cmd/jimmctl/cmd/migratemodel_test.go index 03f3e116f..0d837b2ae 100644 --- a/cmd/jimmctl/cmd/migratemodel_test.go +++ b/cmd/jimmctl/cmd/migratemodel_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test diff --git a/cmd/jimmctl/cmd/modelstatus.go b/cmd/jimmctl/cmd/modelstatus.go index a5822d607..5997df097 100644 --- a/cmd/jimmctl/cmd/modelstatus.go +++ b/cmd/jimmctl/cmd/modelstatus.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd diff --git a/cmd/jimmctl/cmd/modelstatus_test.go b/cmd/jimmctl/cmd/modelstatus_test.go index 5dca3bb03..25b2d44d0 100644 --- a/cmd/jimmctl/cmd/modelstatus_test.go +++ b/cmd/jimmctl/cmd/modelstatus_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test diff --git a/cmd/jimmctl/cmd/package_test.go b/cmd/jimmctl/cmd/package_test.go index fb57779d4..4addb4e34 100644 --- a/cmd/jimmctl/cmd/package_test.go +++ b/cmd/jimmctl/cmd/package_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test diff --git a/cmd/jimmctl/cmd/purge_logs.go b/cmd/jimmctl/cmd/purge_logs.go index faf4abab8..d6f5b0973 100644 --- a/cmd/jimmctl/cmd/purge_logs.go +++ b/cmd/jimmctl/cmd/purge_logs.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package cmd import ( diff --git a/cmd/jimmctl/cmd/purge_logs_test.go b/cmd/jimmctl/cmd/purge_logs_test.go index f472a1b4d..0cd8c5832 100644 --- a/cmd/jimmctl/cmd/purge_logs_test.go +++ b/cmd/jimmctl/cmd/purge_logs_test.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package cmd_test import ( @@ -84,7 +85,7 @@ func (s *purgeLogsSuite) TestPurgeLogsFromDb(c *gc.C) { c.Assert(err, gc.IsNil) tomorrow := relativeNow.AddDate(0, 0, 1).Format(layout) - //alice is superuser + // alice is superuser bClient := jimmtest.NewUserSessionLogin(c, "alice") cmdCtx, err := cmdtesting.RunCommand(c, cmd.NewPurgeLogsCommandForTesting(s.ClientStore(), bClient), tomorrow) c.Assert(err, gc.IsNil) diff --git a/cmd/jimmctl/cmd/relation.go b/cmd/jimmctl/cmd/relation.go index 6249d3b58..3b2249fda 100644 --- a/cmd/jimmctl/cmd/relation.go +++ b/cmd/jimmctl/cmd/relation.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package cmd @@ -179,7 +179,7 @@ type addRelationCommand struct { relation string targetObject string - filename string //optional + filename string // optional } // Info implements the cmd.Command interface. @@ -270,7 +270,7 @@ type removeRelationCommand struct { relation string targetObject string - filename string //optional + filename string // optional } // Info implements the cmd.Command interface. @@ -415,7 +415,10 @@ func formatCheckRelationString(writer io.Writer, value interface{}) error { if !ok { return errors.E("failed to parse access result") } - writer.Write([]byte((&accessResult).setMessage().Msg)) + _, err := writer.Write([]byte((&accessResult).setMessage().Msg)) + if err != nil { + return errors.E("failed to write access result", err) + } return nil } @@ -438,10 +441,13 @@ func (c *checkRelationCommand) Run(ctxt *cmd.Context) error { if err != nil { return err } - c.out.Write(ctxt, *(&accessResult{ + err = c.out.Write(ctxt, *(&accessResult{ Tuple: c.tuple, Allowed: resp.Allowed, }).setMessage()) + if err != nil { + return err + } return nil } diff --git a/cmd/jimmctl/cmd/relation_test.go b/cmd/jimmctl/cmd/relation_test.go index f18edcc9d..3464910b8 100644 --- a/cmd/jimmctl/cmd/relation_test.go +++ b/cmd/jimmctl/cmd/relation_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test @@ -175,7 +175,7 @@ func (s *relationSuite) TestRemoveRelationSuperuser(c *gc.C) { {testName: "Remove Group Relation", input: tuple{user: "group-" + group1 + "#member", relation: "member", target: "group-" + group2}, err: false}, } - //Create groups and relation + // Create groups and relation _, err := s.JimmCmdSuite.JIMM.Database.AddGroup(context.Background(), group1) c.Assert(err, gc.IsNil) _, err = s.JimmCmdSuite.JIMM.Database.AddGroup(context.Background(), group2) @@ -360,11 +360,11 @@ func (s *relationSuite) TestListRelations(c *gc.C) { }, { Object: "group-group-1#member", Relation: "administrator", - TargetObject: "model-" + env.controllers[0].Name + ":" + env.models[0].OwnerIdentityName + "/" + env.models[0].Name, + TargetObject: "model-" + env.models[0].OwnerIdentityName + "/" + env.models[0].Name, }, { Object: "user-" + env.users[1].Name, Relation: "administrator", - TargetObject: "applicationoffer-" + env.controllers[0].Name + ":" + env.applicationOffers[0].Model.OwnerIdentityName + "/" + env.applicationOffers[0].Model.Name + "." + env.applicationOffers[0].Name, + TargetObject: "applicationoffer-" + env.applicationOffers[0].URL, }, { Object: "user-" + env.users[0].Name, Relation: "administrator", @@ -573,7 +573,7 @@ func (s *relationSuite) TestCheckRelationViaSuperuser(c *gc.C) { // Test reader is OK userToCheck := "user-" + u.Name - modelToCheck := "model-" + controller.Name + ":" + u.Name + "/" + model.Name + modelToCheck := "model-" + u.Name + "/" + model.Name cmdCtx, err := cmdtesting.RunCommand( c, cmd.NewCheckRelationCommandForTesting(s.ClientStore(), bClient), diff --git a/cmd/jimmctl/cmd/removecloudfromcontroller.go b/cmd/jimmctl/cmd/removecloudfromcontroller.go index 318053479..5f5caff21 100644 --- a/cmd/jimmctl/cmd/removecloudfromcontroller.go +++ b/cmd/jimmctl/cmd/removecloudfromcontroller.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd diff --git a/cmd/jimmctl/cmd/removecloudfromcontroller_test.go b/cmd/jimmctl/cmd/removecloudfromcontroller_test.go index 037180b43..ca7d10475 100644 --- a/cmd/jimmctl/cmd/removecloudfromcontroller_test.go +++ b/cmd/jimmctl/cmd/removecloudfromcontroller_test.go @@ -1,10 +1,9 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test import ( "github.com/juju/cmd/v3/cmdtesting" jujutesting "github.com/juju/testing" - gc "gopkg.in/check.v1" "github.com/canonical/jimm/v3/cmd/jimmctl/cmd" diff --git a/cmd/jimmctl/cmd/removecontroller.go b/cmd/jimmctl/cmd/removecontroller.go index 381fcf53b..63589741d 100644 --- a/cmd/jimmctl/cmd/removecontroller.go +++ b/cmd/jimmctl/cmd/removecontroller.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd diff --git a/cmd/jimmctl/cmd/removecontroller_test.go b/cmd/jimmctl/cmd/removecontroller_test.go index 47a03954d..29103f6a6 100644 --- a/cmd/jimmctl/cmd/removecontroller_test.go +++ b/cmd/jimmctl/cmd/removecontroller_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test diff --git a/cmd/jimmctl/cmd/revokeauditlogaccess.go b/cmd/jimmctl/cmd/revokeauditlogaccess.go index 525018f44..2341970c3 100644 --- a/cmd/jimmctl/cmd/revokeauditlogaccess.go +++ b/cmd/jimmctl/cmd/revokeauditlogaccess.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd @@ -58,7 +58,7 @@ func (c *revokeAuditLogAccessCommand) SetFlags(f *gnuflag.FlagSet) { // Init implements the cmd.Command interface. func (c *revokeAuditLogAccessCommand) Init(args []string) error { - if len(args) < 0 { + if len(args) == 0 { return errors.E("missing username") } c.username, args = args[0], args[1:] diff --git a/cmd/jimmctl/cmd/revokeauditlogaccess_test.go b/cmd/jimmctl/cmd/revokeauditlogaccess_test.go index ca70e9f85..f87c24dd2 100644 --- a/cmd/jimmctl/cmd/revokeauditlogaccess_test.go +++ b/cmd/jimmctl/cmd/revokeauditlogaccess_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test @@ -15,8 +15,7 @@ type revokeAuditLogAccessSuite struct { cmdtest.JimmCmdSuite } -// TODO (alesstimec) uncomment when grant/revoke is implemented -//var _ = gc.Suite(&revokeAuditLogAccessSuite{}) +var _ = gc.Suite(&revokeAuditLogAccessSuite{}) func (s *revokeAuditLogAccessSuite) TestRevokeAuditLogAccessSuperuser(c *gc.C) { // alice is superuser diff --git a/cmd/jimmctl/cmd/setcontrollerdeprecated.go b/cmd/jimmctl/cmd/setcontrollerdeprecated.go index 01c6cb90c..851970d8e 100644 --- a/cmd/jimmctl/cmd/setcontrollerdeprecated.go +++ b/cmd/jimmctl/cmd/setcontrollerdeprecated.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd @@ -63,7 +63,7 @@ func (c *setControllerDeprecatedCommand) SetFlags(f *gnuflag.FlagSet) { // Init implements the cmd.Command interface. func (c *setControllerDeprecatedCommand) Init(args []string) error { - if len(args) < 0 { + if len(args) == 0 { return errors.E("missing controller name") } c.controllerName, args = args[0], args[1:] diff --git a/cmd/jimmctl/cmd/setcontrollerdeprecated_test.go b/cmd/jimmctl/cmd/setcontrollerdeprecated_test.go index b1411d034..a8a8de5c0 100644 --- a/cmd/jimmctl/cmd/setcontrollerdeprecated_test.go +++ b/cmd/jimmctl/cmd/setcontrollerdeprecated_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test diff --git a/cmd/jimmctl/cmd/updatemigratedmodel.go b/cmd/jimmctl/cmd/updatemigratedmodel.go index 700480c2d..3ada94bb6 100644 --- a/cmd/jimmctl/cmd/updatemigratedmodel.go +++ b/cmd/jimmctl/cmd/updatemigratedmodel.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd diff --git a/cmd/jimmctl/cmd/updatemigratedmodel_test.go b/cmd/jimmctl/cmd/updatemigratedmodel_test.go index caf53eef5..da1469f9e 100644 --- a/cmd/jimmctl/cmd/updatemigratedmodel_test.go +++ b/cmd/jimmctl/cmd/updatemigratedmodel_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package cmd_test diff --git a/cmd/jimmctl/main.go b/cmd/jimmctl/main.go index bdeee2970..3555cdd8a 100644 --- a/cmd/jimmctl/main.go +++ b/cmd/jimmctl/main.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package main diff --git a/cmd/jimmsrv/main.go b/cmd/jimmsrv/main.go index f2c540913..4f5698bd3 100644 --- a/cmd/jimmsrv/main.go +++ b/cmd/jimmsrv/main.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package main @@ -35,6 +35,8 @@ func main() { } // start initialises the jimmsrv service. +// +//nolint:gocognit // Start function to be ignored. func start(ctx context.Context, s *service.Service) error { zapctx.Info(ctx, "jimm info", zap.String("version", version.VersionInfo.Version), @@ -204,15 +206,19 @@ func start(ctx context.Context, s *service.Service) error { } httpsrv := &http.Server{ - Addr: addr, - Handler: jimmsvc, + Addr: addr, + Handler: jimmsvc, + ReadHeaderTimeout: time.Second * 5, } s.OnShutdown(func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() zapctx.Warn(ctx, "server shutdown triggered") - httpsrv.Shutdown(ctx) + err = httpsrv.Shutdown(ctx) + if err != nil { + zapctx.Error(ctx, "failed to shutdown server gracefully", zap.Error(err)) + } jimmsvc.Cleanup() }) s.Go(httpsrv.ListenAndServe) diff --git a/cmd/jimmsrv/service/export_test.go b/cmd/jimmsrv/service/export_test.go index 3dca85934..db33a96a1 100644 --- a/cmd/jimmsrv/service/export_test.go +++ b/cmd/jimmsrv/service/export_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package service var NewOpenFGAClient = newOpenFGAClient diff --git a/cmd/jimmsrv/service/service.go b/cmd/jimmsrv/service/service.go index 978786fba..573046dbb 100644 --- a/cmd/jimmsrv/service/service.go +++ b/cmd/jimmsrv/service/service.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. // service defines the methods necessary to start a JIMM server // alongside all the config options that can be supplied to configure JIMM. diff --git a/cmd/jimmsrv/service/service_test.go b/cmd/jimmsrv/service/service_test.go index 98d0ad81c..07d4ed519 100644 --- a/cmd/jimmsrv/service/service_test.go +++ b/cmd/jimmsrv/service/service_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package service_test @@ -51,6 +51,7 @@ func TestDefaultService(t *testing.T) { c.Assert(err, qt.IsNil) svc.ServeHTTP(rr, req) resp := rr.Result() + defer resp.Body.Close() c.Check(resp.StatusCode, qt.Equals, http.StatusOK) } @@ -266,6 +267,7 @@ func TestPublicKey(t *testing.T) { response, err := srv.Client().Get(srv.URL + "/macaroons/publickey") c.Assert(err, qt.IsNil) + defer response.Body.Close() data, err := io.ReadAll(response.Body) c.Assert(err, qt.IsNil) c.Assert(string(data), qt.Equals, `{"PublicKey":"izcYsQy3TePp6bLjqOo3IRPFvkQd2IKtyODGqC6SdFk="}`) @@ -290,6 +292,7 @@ func TestRebacAdminApi(t *testing.T) { response, err := srv.Client().Get(srv.URL + "/rebac/v1/swagger.json") c.Assert(err, qt.IsNil) + defer response.Body.Close() c.Assert(response.StatusCode, qt.Equals, 401) } @@ -433,6 +436,7 @@ func TestDisableOAuthEndpointsWhenDashboardRedirectURLNotSet(t *testing.T) { response, err := srv.Client().Get(srv.URL + "/auth/whoami") c.Assert(err, qt.IsNil) + defer response.Body.Close() c.Assert(response.StatusCode, qt.Equals, http.StatusNotFound) } @@ -456,6 +460,7 @@ func TestEnableOAuthEndpointsWhenDashboardRedirectURLSet(t *testing.T) { response, err := srv.Client().Get(srv.URL + "/auth/whoami") c.Assert(err, qt.IsNil) + defer response.Body.Close() c.Assert(response.StatusCode, qt.Not(qt.Equals), http.StatusNotFound) } diff --git a/compose-common.yaml b/compose-common.yaml new file mode 100644 index 000000000..b1f9727e7 --- /dev/null +++ b/compose-common.yaml @@ -0,0 +1,62 @@ +# This file contains a collection of common configurations used in JIMM's Docker compose file. + +services: + jimm-base: + environment: + JIMM_LOG_LEVEL: "debug" + JIMM_UUID: "3217dbc9-8ea9-4381-9e97-01eab0b3f6bb" + JIMM_DSN: "postgresql://jimm:jimm@db/jimm" + # Not needed for local test (yet). + # BAKERY_AGENT_FILE: "" + JIMM_ADMINS: "jimm-test@canonical.com" + # Note: You can comment out the Vault ENV vars below and instead use INSECURE_SECRET_STORAGE to place secrets in Postgres. + VAULT_ADDR: "http://vault:8200" + VAULT_PATH: "/jimm-kv/" + VAULT_ROLE_ID: test-role-id + VAULT_ROLE_SECRET_ID: test-secret-id + # Note: By default we should use Vault as that is the primary means of secret storage. + # INSECURE_SECRET_STORAGE: "enabled" + # JIMM_DASHBOARD_LOCATION: "" + JIMM_DNS_NAME: "jimm.localhost" + JIMM_LISTEN_ADDR: "0.0.0.0:80" + JIMM_TEST_PGXDSN: "postgresql://jimm:jimm@db/jimm" + JIMM_JWT_EXPIRY: 30s + JIMM_AUDIT_LOG_RETENTION_PERIOD_IN_DAYS: "1" + TEST_LOGGING_CONFIG: "" + BAKERY_PUBLIC_KEY: "izcYsQy3TePp6bLjqOo3IRPFvkQd2IKtyODGqC6SdFk=" + BAKERY_PRIVATE_KEY: "ly/dzsI9Nt/4JxUILQeAX79qZ4mygDiuYGqc2ZEiDEc=" + OPENFGA_SCHEME: "http" + OPENFGA_HOST: "openfga" + OPENFGA_PORT: 8080 + OPENFGA_STORE: "01GP1254CHWJC1MNGVB0WDG1T0" + OPENFGA_AUTH_MODEL: "01GP1EC038KHGB6JJ2XXXXCXKB" + OPENFGA_TOKEN: "jimm" + JIMM_IS_LEADER: true + JIMM_OAUTH_ISSUER_URL: "http://keycloak.localhost:8082/realms/jimm" # Scheme required + JIMM_OAUTH_CLIENT_ID: "jimm-device" + JIMM_OAUTH_CLIENT_SECRET: "SwjDofnbDzJDm9iyfUhEp67FfUFMY8L4" + JIMM_OAUTH_SCOPES: "openid profile email" # Space separated list of scopes + JIMM_DASHBOARD_FINAL_REDIRECT_URL: "https://jaas.ai" # Example URL + JIMM_ACCESS_TOKEN_EXPIRY_DURATION: 100h + JIMM_SECURE_SESSION_COOKIES: false + JIMM_SESSION_COOKIE_MAX_AGE: 86400 + JIMM_SESSION_SECRET_KEY: Xz2RkR9g87M75xfoumhEs5OmGziIX8D88Rk5YW8FSvkBPSgeK9t5AS9IvPDJ3NnB + healthcheck: + test: [ "CMD", "curl", "http://jimm.localhost:80" ] + interval: 5s + timeout: 5s + retries: 50 # Should fail after approximately (interval*retry) seconds + depends_on: + db: + condition: service_healthy + openfga: + condition: service_healthy + traefik: + condition: service_healthy + keycloak: + condition: service_healthy + labels: + traefik.enable: true + traefik.http.routers.jimm.rule: Host(`jimm.localhost`) + traefik.http.routers.jimm.entrypoints: websecure + traefik.http.routers.jimm.tls: true diff --git a/doc/golangci-lint.md b/doc/golangci-lint.md new file mode 100644 index 000000000..f72fc0591 --- /dev/null +++ b/doc/golangci-lint.md @@ -0,0 +1,10 @@ +# Golangci-lint +We use golangci-lint as the linter for this project. It is helpful to run +the linter as your default vscode linter. + +In the .vscode folder of this repository you will find it is defined as the linter of choice. + +To install, please install the golangci-lint binary or install it via "go install". + +The version this was tested with is: +```go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.60.1``` \ No newline at end of file diff --git a/doc/versioning.md b/doc/versioning.md new file mode 100644 index 000000000..1db6973aa --- /dev/null +++ b/doc/versioning.md @@ -0,0 +1,21 @@ +## Version History + +JIMM v0 and v1 follow a different versioning strategy than future releases. JIMM v0 was the initial +release and used MongoDB to store state. JIMM v1 was an upgrade that switched to using PostgreSQL +for storing state but still retained similar functionality to v0. +These versions worked with Juju v2 and v3. + +Subsequently JIMM introduced a large shift in how the service worked: +- JIMM now acts as a proxy between all client and Juju controller interactions. Previously +users were redirected to a Juju controller. +- Juju controllers support JWT login where secure tokens are issued by JIMM. +- JIMM acts as an authorisation gateway creating trusted short-lived JWT tokens to authorize +user actions against Juju controllers. + +The above work encompassed a breaking change and required changes in Juju (requiring a +Juju controller of at least version 3.3). + +Further, to better align the two projects, JIMM's versioning now aligns with Juju. + +As a result of this, there is no JIMM v2 and instead from JIMM v3, the versioning strategy +we follow is to match JIMM's major version to the corresponding Juju major version we support. diff --git a/docker-compose.yaml b/docker-compose.yaml index 51fbaee36..c2c352393 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,7 +2,7 @@ services: traefik: image: "traefik:2.9" container_name: traefik - profiles: ["dev"] + profiles: ["dev", "test"] ports: - "80:80" - "443:443" @@ -19,8 +19,29 @@ services: interval: 10s timeout: 5s retries: 3 + + # An instance of JIMM used in integration tests, pulled from a tag. + jimm-test: + extends: + file: compose-common.yaml + service: jimm-base + image: ghcr.io/canonical/jimm:${JIMM_VERSION:-latest} + profiles: ["test"] + container_name: jimm-test + ports: + - 17070:80 + entrypoint: + - bash + - -c + - >- + apt update && apt install curl -y + && /usr/local/bin/jimmsrv - jimm: + # An instance of JIMM used for dev, built from source with hot-reloading. + jimm-dev: + extends: + file: compose-common.yaml + service: jimm-base image: cosmtrek/air:latest profiles: ["dev"] # working_dir value has to be the same of mapped volume @@ -36,78 +57,15 @@ services: ports: - 17070:80 - 2345:2345 - environment: - JIMM_LOG_LEVEL: "debug" - JIMM_UUID: "3217dbc9-8ea9-4381-9e97-01eab0b3f6bb" - JIMM_DSN: "postgresql://jimm:jimm@db/jimm" - # Not needed for local test (yet). - # BAKERY_AGENT_FILE: "" - JIMM_ADMINS: "jimm-test@canonical.com" - # Note: You can comment out the Vault ENV vars below and instead use INSECURE_SECRET_STORAGE to place secrets in Postgres. - VAULT_ADDR: "http://vault:8200" - VAULT_PATH: "/jimm-kv/" - # Note: By default we should use Vault as that is the primary means of secret storage. - # INSECURE_SECRET_STORAGE: "enabled" - # JIMM_DASHBOARD_LOCATION: "" - JIMM_DNS_NAME: "jimm.localhost" - JIMM_LISTEN_ADDR: "0.0.0.0:80" - JIMM_TEST_PGXDSN: "postgresql://jimm:jimm@db/jimm" - JIMM_JWT_EXPIRY: 30s - JIMM_AUDIT_LOG_RETENTION_PERIOD_IN_DAYS: "1" - TEST_LOGGING_CONFIG: "" - BAKERY_PUBLIC_KEY: "izcYsQy3TePp6bLjqOo3IRPFvkQd2IKtyODGqC6SdFk=" - BAKERY_PRIVATE_KEY: "ly/dzsI9Nt/4JxUILQeAX79qZ4mygDiuYGqc2ZEiDEc=" - OPENFGA_SCHEME: "http" - OPENFGA_HOST: "openfga" - OPENFGA_PORT: 8080 - OPENFGA_STORE: "01GP1254CHWJC1MNGVB0WDG1T0" - OPENFGA_AUTH_MODEL: "01GP1EC038KHGB6JJ2XXXXCXKB" - OPENFGA_TOKEN: "jimm" - JIMM_IS_LEADER: true - JIMM_OAUTH_ISSUER_URL: "http://keycloak.localhost:8082/realms/jimm" # Scheme required - JIMM_OAUTH_CLIENT_ID: "jimm-device" - JIMM_OAUTH_CLIENT_SECRET: "SwjDofnbDzJDm9iyfUhEp67FfUFMY8L4" - JIMM_OAUTH_SCOPES: "openid profile email" # Space separated list of scopes - JIMM_DASHBOARD_FINAL_REDIRECT_URL: "https://jaas.ai" # Example URL - JIMM_ACCESS_TOKEN_EXPIRY_DURATION: 1h - JIMM_SECURE_SESSION_COOKIES: false - JIMM_SESSION_COOKIE_MAX_AGE: 86400 - JIMM_SESSION_SECRET_KEY: Xz2RkR9g87M75xfoumhEs5OmGziIX8D88Rk5YW8FSvkBPSgeK9t5AS9IvPDJ3NnB volumes: - ./:/jimm/ - - ./local/vault/approle.json:/vault/approle.json:rw - - ./local/vault/roleid.txt:/vault/roleid.txt:rw - - ./local/vault/vault.env:/vault/vault.env:rw - healthcheck: - test: [ "CMD", "curl", "http://jimm.localhost:80" ] - interval: 5s - timeout: 5s - retries: 50 # Should fail after approximately (interval*retry) seconds - depends_on: - db: - condition: service_healthy - openfga: - condition: service_healthy - traefik: - condition: service_healthy - insert-hardcoded-auth-model: - condition: service_completed_successfully - keycloak: - condition: service_healthy - labels: - traefik.enable: true - traefik.http.routers.jimm.rule: Host(`jimm.localhost`) - traefik.http.routers.jimm.entrypoints: websecure - traefik.http.routers.jimm.tls: true db: image: postgres container_name: postgres - restart: always + restart: on-failure ports: - 5432:5432 - volumes: - - ./local/init.sql:/docker-entrypoint-initdb.d/init.sql environment: POSTGRES_DB: jimm POSTGRES_USER: jimm @@ -122,90 +80,36 @@ services: retries: 5 vault: - image: hashicorp/vault:latest + build: + context: ./local/vault/ + dockerfile: Dockerfile container_name: vault ports: - 8200:8200 environment: - # For CLI VAULT_ADDR: "http://localhost:8200" - # Dev Flag VAULT_DEV_LISTEN_ADDRESS: "0.0.0.0:8200" - # Dev Flag - VAULT_DEV_ROOT_TOKEN_ID: "token" + VAULT_DEV_ROOT_TOKEN_ID: "root" cap_add: - IPC_LOCK - volumes: - - ./local/vault/vault.hcl:/vault/config/vault.hcl - - ./local/vault/init.sh:/vault/init.sh - - ./local/vault/policy.hcl:/vault/policy.hcl - - ./local/vault/approle.json:/vault/approle.json - - ./local/vault/roleid.txt:/vault/roleid.txt:rw - - ./local/vault/vault.env:/vault/vault.env:rw - command: /vault/init.sh - depends_on: - db: - condition: service_healthy - - migrateopenfga: - image: openfga/openfga:v1.2.0 - container_name: migrateopenfga - command: migrate --datastore-engine postgres --datastore-uri 'postgresql://jimm:jimm@db/jimm?sslmode=disable' - depends_on: - db: - condition: service_healthy - - insert-hardcoded-store: - image: governmentpaas/psql - container_name: insert-hardcoded-store - command: psql -Atx postgresql://jimm:jimm@db/jimm?sslmode=disable -c "INSERT INTO store (id,name,created_at,updated_at) VALUES ('01GP1254CHWJC1MNGVB0WDG1T0','jimm',NOW(),NOW());" - depends_on: - migrateopenfga: - condition: service_completed_successfully openfga: - # We use our 'image' to mimic juju standard. - # image: openfga/openfga:latest build: - context: . - dockerfile: ./local/openfga/Dockerfile + context: ./local/openfga/ + dockerfile: Dockerfile container_name: openfga environment: OPENFGA_AUTHN_METHOD: "preshared" OPENFGA_AUTHN_PRESHARED_KEYS: "jimm" OPENFGA_DATASTORE_ENGINE: "postgres" OPENFGA_DATASTORE_URI: "postgresql://jimm:jimm@db/jimm?sslmode=disable" - command: run + volumes: + - ./openfga/authorisation_model.json:/app/authorisation_model.json ports: - 8080:8080 - 3000:3000 depends_on: - migrateopenfga: - condition: service_completed_successfully - insert-hardcoded-store: - condition: service_completed_successfully - healthcheck: - test: [ "CMD", "curl", "http://0.0.0.0:8080/healthz" ] - interval: 5s - timeout: 5s - retries: 10 - - # Adds the auth model and updates its authorisation model id to be the expected hard-coded id such that our local JIMM can utilise it for queries. - # The auth model json is retrieved from file via volume mount. - insert-hardcoded-auth-model: - profiles: ["dev"] - image: governmentpaas/psql - container_name: insert-hardcoded-auth-model - volumes: - - ./local/openfga/authorisation_model.json:/authorisation_model.json - command: - - /bin/sh - - -c - - | - wget -q -O - --header 'Content-Type: application/json' --header 'Authorization: Bearer jimm' --post-file authorisation_model.json openfga:8080/stores/01GP1254CHWJC1MNGVB0WDG1T0/authorization-models - psql -Atx postgresql://jimm:jimm@db/jimm?sslmode=disable -c "UPDATE authorization_model SET authorization_model_id = '01GP1EC038KHGB6JJ2XXXXCXKB' WHERE store = '01GP1254CHWJC1MNGVB0WDG1T0';" - depends_on: - openfga: + db: condition: service_healthy keycloak: diff --git a/internal/auth/jujuauth.go b/internal/auth/jujuauth.go index 6e8abbfbf..c5729c78a 100644 --- a/internal/auth/jujuauth.go +++ b/internal/auth/jujuauth.go @@ -1,4 +1,4 @@ -// Copyright 2021 canonical. +// Copyright 2024 Canonical. package auth diff --git a/internal/auth/oauth2.go b/internal/auth/oauth2.go index 4cc8deb7d..239018464 100644 --- a/internal/auth/oauth2.go +++ b/internal/auth/oauth2.go @@ -1,4 +1,4 @@ -// Copyright 2024 canonical. +// Copyright 2024 Canonical. // Package auth provides means to authenticate users into JIMM. // diff --git a/internal/auth/oauth2_test.go b/internal/auth/oauth2_test.go index 756468a8a..fa6ac6999 100644 --- a/internal/auth/oauth2_test.go +++ b/internal/auth/oauth2_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 canonical. +// Copyright 2024 Canonical. package auth_test @@ -16,19 +16,20 @@ import ( "time" "github.com/antonlindstrom/pgstore" + "github.com/coreos/go-oidc/v3/oidc" + qt "github.com/frankban/quicktest" + "github.com/gorilla/sessions" + "github.com/canonical/jimm/v3/internal/auth" "github.com/canonical/jimm/v3/internal/db" "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/errors" "github.com/canonical/jimm/v3/internal/jimmtest" - "github.com/coreos/go-oidc/v3/oidc" - qt "github.com/frankban/quicktest" - "github.com/gorilla/sessions" ) func setupTestAuthSvc(ctx context.Context, c *qt.C, expiry time.Duration) (*auth.AuthenticationService, *db.Database, sessions.Store, func()) { db := &db.Database{ - DB: jimmtest.PostgresDB(c, func() time.Time { return time.Now() }), + DB: jimmtest.PostgresDB(c, time.Now), } c.Assert(db.Migrate(ctx, false), qt.IsNil) @@ -251,7 +252,8 @@ func TestVerifyClientCredentials(t *testing.T) { const ( // these are valid client credentials hardcoded into the jimm realm - validClientID = "test-client-id" + validClientID = "test-client-id" + //nolint:gosec // Thinks hardcoded credentials. validClientSecret = "2M2blFbO4GX4zfggQpivQSxwWX1XGgNf" ) @@ -265,7 +267,7 @@ func TestVerifyClientCredentials(t *testing.T) { c.Assert(err, qt.ErrorMatches, "invalid client credentials") } -func assertSetCookiesIsCorrect(c *qt.C, rec *httptest.ResponseRecorder, parsedCookies []*http.Cookie) { +func assertSetCookiesIsCorrect(c *qt.C, parsedCookies []*http.Cookie) { assertHasCookie := func(name string, cookies []*http.Cookie) { found := false for _, v := range cookies { @@ -298,7 +300,7 @@ func TestCreateBrowserSession(t *testing.T) { cookies := rec.Header().Get("Set-Cookie") parsedCookies := jimmtest.ParseCookies(cookies) - assertSetCookiesIsCorrect(c, rec, parsedCookies) + assertSetCookiesIsCorrect(c, parsedCookies) req.AddCookie(&http.Cookie{ Name: auth.SessionName, @@ -345,7 +347,7 @@ func TestAuthenticateBrowserSessionAndLogout(t *testing.T) { // Assert Set-Cookie present setCookieCookies := rec.Header().Get("Set-Cookie") parsedCookies := jimmtest.ParseCookies(setCookieCookies) - assertSetCookiesIsCorrect(c, rec, parsedCookies) + assertSetCookiesIsCorrect(c, parsedCookies) // Test logout does indeed remove the cookie for us err = authSvc.Logout(ctx, rec, req) @@ -454,7 +456,7 @@ func TestAuthenticateBrowserSessionHandlesExpiredAccessTokens(t *testing.T) { // Assert Set-Cookie present setCookieCookies := rec.Header().Get("Set-Cookie") parsedCookies := jimmtest.ParseCookies(setCookieCookies) - assertSetCookiesIsCorrect(c, rec, parsedCookies) + assertSetCookiesIsCorrect(c, parsedCookies) } func TestAuthenticateBrowserSessionHandlesMissingOrExpiredRefreshTokens(t *testing.T) { @@ -492,7 +494,8 @@ func TestAuthenticateBrowserSessionHandlesMissingOrExpiredRefreshTokens(t *testi // And we're missing a refresh token (the same case would apply for an expired refresh token // or any scenario where the token source cannot refresh the access token) u.RefreshToken = "" - db.UpdateIdentity(ctx, u) + err = db.UpdateIdentity(ctx, u) + c.Assert(err, qt.IsNil) // AuthenticateBrowserSession should fail to refresh the users session and delete // the current session, giving us the same cookie back with a max-age of -1. diff --git a/internal/cloudcred/cloudcred.go b/internal/cloudcred/cloudcred.go index 78e372087..9899f1c8c 100644 --- a/internal/cloudcred/cloudcred.go +++ b/internal/cloudcred/cloudcred.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd.package cloudcred +// Copyright 2024 Canonical. //go:generate go run generate.go -o attr.go diff --git a/internal/cloudcred/cloudcred_test.go b/internal/cloudcred/cloudcred_test.go index 58186e84d..63b9d1931 100644 --- a/internal/cloudcred/cloudcred_test.go +++ b/internal/cloudcred/cloudcred_test.go @@ -1,12 +1,13 @@ -// Copyright 2020 Canonical Ltd.package cloudcred +// Copyright 2024 Canonical. package cloudcred_test import ( "testing" - "github.com/canonical/jimm/v3/internal/cloudcred" qt "github.com/frankban/quicktest" + + "github.com/canonical/jimm/v3/internal/cloudcred" ) func TestIsVisibleAttribute(t *testing.T) { diff --git a/internal/cmdtest/cmdsetup.go b/internal/cmdtest/cmdsetup.go new file mode 100644 index 000000000..fb3ad2cce --- /dev/null +++ b/internal/cmdtest/cmdsetup.go @@ -0,0 +1,37 @@ +// Copyright 2024 Canonical. +package cmdtest + +import ( + "context" + "crypto/tls" + "strings" + + "github.com/gorilla/websocket" + jujuapi "github.com/juju/juju/api" + "github.com/juju/juju/rpc/jsoncodec" +) + +func TestDialOpts(lp jujuapi.LoginProvider) *jujuapi.DialOpts { + return &jujuapi.DialOpts{ + LoginProvider: lp, + DialWebsocket: getDialWebsocketWithInsecureUrl(), + } +} + +// getDialWebsocketWithInsecureUrl forces the URL used for dialing to use insecure websockets +// so that tests don't need to start an HTTPS server and manage certs. +func getDialWebsocketWithInsecureUrl() func(ctx context.Context, urlStr string, tlsConfig *tls.Config, ipAddr string) (jsoncodec.JSONConn, error) { + // Modified from github.com/juju/juju@v0.0.0-20240304110523-55fb5d03683b/api/apiclient.go gorillaDialWebsocket + + dialWebsocket := func(ctx context.Context, urlStr string, tlsConfig *tls.Config, ipAddr string) (jsoncodec.JSONConn, error) { + urlStr = strings.Replace(urlStr, "wss", "ws", 1) + dialer := &websocket.Dialer{} + c, resp, err := dialer.Dial(urlStr, nil) + defer resp.Body.Close() + if err != nil { + return nil, err + } + return jsoncodec.NewWebsocketConn(c), nil + } + return dialWebsocket +} diff --git a/internal/cmdtest/jimmsuite.go b/internal/cmdtest/jimmsuite.go index 7a370c588..bb67fbe08 100644 --- a/internal/cmdtest/jimmsuite.go +++ b/internal/cmdtest/jimmsuite.go @@ -1,19 +1,15 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. // Package cmdtest provides the test suite used for CLI tests // as well as helper functions used for integration based CLI tests. package cmdtest import ( - "bytes" "context" - "crypto/tls" - "encoding/pem" "net/http" "net/http/httptest" "net/url" "os" - "path/filepath" "strings" "time" @@ -58,8 +54,7 @@ func (s *JimmCmdSuite) SetUpTest(c *gc.C) { s.cancel = cancel s.HTTP = httptest.NewUnstartedServer(nil) - s.HTTP.TLS = setupTLS(c) - u, err := url.Parse("https://" + s.HTTP.Listener.Addr().String()) + u, err := url.Parse("http://" + s.HTTP.Listener.Addr().String()) c.Assert(err, gc.Equals, nil) ofgaClient, cofgaClient, cofgaParams, err := jimmtest.SetupTestOFGAClient(c.TestName()) @@ -71,7 +66,7 @@ func (s *JimmCmdSuite) SetUpTest(c *gc.C) { s.Params = jimmtest.NewTestJimmParams(&jimmtest.GocheckTester{C: c}) dsn, err := url.Parse(s.Params.DSN) c.Assert(err, gc.Equals, nil) - s.databaseName = strings.Replace(dsn.Path, "/", "", -1) + s.databaseName = strings.ReplaceAll(dsn.Path, "/", "") s.Params.PublicDNSName = u.Host s.Params.ControllerAdmins = []string{"admin"} s.Params.OpenFGAParams = service.OpenFGAParams{ @@ -90,14 +85,14 @@ func (s *JimmCmdSuite) SetUpTest(c *gc.C) { c.Assert(err, gc.Equals, nil) s.Service = srv s.JIMM = srv.JIMM() - s.HTTP.Config = &http.Server{Handler: srv} + s.HTTP.Config = &http.Server{Handler: srv, ReadHeaderTimeout: time.Second * 5} err = s.Service.StartJWKSRotator(ctx, time.NewTicker(time.Hour).C, time.Now().UTC().AddDate(0, 3, 0)) c.Assert(err, gc.Equals, nil) - s.HTTP.StartTLS() + s.HTTP.Start() - // NOW we can set up the juju conn suites + // Now we can set up the juju conn suites s.ControllerConfigAttrs = map[string]interface{}{ "login-token-refresh-url": u.String() + "/.well-known/jwks.json", } @@ -108,22 +103,8 @@ func (s *JimmCmdSuite) SetUpTest(c *gc.C) { s.AdminUser = i s.AdminUser.LastLogin = db.Now() - err = s.JIMM.Database.GetIdentity(ctx, s.AdminUser) - c.Assert(err, gc.Equals, nil) - - alice := openfga.NewUser(s.AdminUser, ofgaClient) - err = alice.SetControllerAccess(context.Background(), s.JIMM.ResourceTag(), ofganames.AdministratorRelation) - c.Assert(err, gc.Equals, nil) - s.AddAdminUser(c, "alice@canonical.com") - w := new(bytes.Buffer) - err = pem.Encode(w, &pem.Block{ - Type: "CERTIFICATE", - Bytes: s.HTTP.TLS.Certificates[0].Certificate[0], - }) - c.Assert(err, gc.Equals, nil) - s.ClientStore = func() *jjclient.MemStore { store := jjclient.NewMemStore() store.CurrentControllerName = "JIMM" @@ -131,7 +112,6 @@ func (s *JimmCmdSuite) SetUpTest(c *gc.C) { ControllerUUID: jimmtest.ControllerUUID, APIEndpoints: []string{u.Host}, PublicDNSName: s.HTTP.URL, - CACert: w.String(), } return store } @@ -160,43 +140,6 @@ func (s *JimmCmdSuite) TearDownTest(c *gc.C) { s.JujuConnSuite.TearDownTest(c) } -func getRootJimmPath(c *gc.C) string { - path, err := os.Getwd() - c.Assert(err, gc.IsNil) - dirs := strings.Split(path, "/") - c.Assert(len(dirs), gc.Not(gc.Equals), 1) - dirs = dirs[1:] - jimmIndex := -1 - // Range over dirs from the end to ensure no top-level jimm - // folders interfere with our search. - for i := len(dirs) - 1; i >= 0; i-- { - if dirs[i] == "jimm" { - jimmIndex = i + 1 - break - } - } - c.Assert(jimmIndex, gc.Not(gc.Equals), -1) - return "/" + filepath.Join(dirs[:jimmIndex]...) -} - -func setupTLS(c *gc.C) *tls.Config { - jimmPath := getRootJimmPath(c) - pathToCert := filepath.Join(jimmPath, "local/traefik/certs/server.crt") - localhostCert, err := os.ReadFile(pathToCert) - c.Assert(err, gc.IsNil, gc.Commentf("Unable to find cert at %s. Run make cert in root directory.", pathToCert)) - - pathToKey := filepath.Join(jimmPath, "local/traefik/certs/server.key") - localhostKey, err := os.ReadFile(pathToKey) - c.Assert(err, gc.IsNil, gc.Commentf("Unable to find key at %s. Run make cert in root directory.", pathToKey)) - - cert, err := tls.X509KeyPair(localhostCert, localhostKey) - c.Assert(err, gc.IsNil, gc.Commentf("Failed to generate certificate key pair.")) - - tlsConfig := new(tls.Config) - tlsConfig.Certificates = []tls.Certificate{cert} - return tlsConfig -} - func (s *JimmCmdSuite) AddAdminUser(c *gc.C, email string) { identity, err := dbmodel.NewIdentity(email) c.Assert(err, gc.IsNil) diff --git a/internal/common/pagination/entitlement.go b/internal/common/pagination/entitlement.go index 12c01922e..276a07e38 100644 --- a/internal/common/pagination/entitlement.go +++ b/internal/common/pagination/entitlement.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package pagination diff --git a/internal/common/pagination/entitlement_test.go b/internal/common/pagination/entitlement_test.go index d6c5c7431..592f59ba8 100644 --- a/internal/common/pagination/entitlement_test.go +++ b/internal/common/pagination/entitlement_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package pagination_test diff --git a/internal/common/pagination/export_test.go b/internal/common/pagination/export_test.go index 611ea33b4..db80c2be3 100644 --- a/internal/common/pagination/export_test.go +++ b/internal/common/pagination/export_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package pagination diff --git a/internal/common/pagination/pagination.go b/internal/common/pagination/pagination.go index 82380279c..7c0360a99 100644 --- a/internal/common/pagination/pagination.go +++ b/internal/common/pagination/pagination.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. // pagination holds common pagination patterns. package pagination diff --git a/internal/common/pagination/pagination_test.go b/internal/common/pagination/pagination_test.go index 60ce5af93..18c3769cf 100644 --- a/internal/common/pagination/pagination_test.go +++ b/internal/common/pagination/pagination_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package pagination_test diff --git a/internal/common/utils/test_utils.go b/internal/common/utils/test_utils.go index 199871d37..4e0b7fdcc 100644 --- a/internal/common/utils/test_utils.go +++ b/internal/common/utils/test_utils.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package utils func IntToPointer(i int) *int { diff --git a/internal/dashboard/dashboard.go b/internal/dashboard/dashboard.go index 7d0542e9d..4f2fe279b 100644 --- a/internal/dashboard/dashboard.go +++ b/internal/dashboard/dashboard.go @@ -1,5 +1,4 @@ -// Copyright 2020 Canonical Ltd. -// Licensed under the AGPLv3, see LICENCE file for details. +// Copyright 2024 Canonical. // Package dashboard contains a single function that creates a handler for // serving the JAAS Dashboard. diff --git a/internal/dashboard/dashboard_test.go b/internal/dashboard/dashboard_test.go index 773dfb557..99650959e 100644 --- a/internal/dashboard/dashboard_test.go +++ b/internal/dashboard/dashboard_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dashboard_test @@ -43,6 +43,7 @@ func TestDashboardNotConfigured(t *testing.T) { c.Assert(err, qt.IsNil) hnd.ServeHTTP(rr, req) resp := rr.Result() + defer resp.Body.Close() c.Check(resp.StatusCode, qt.Equals, http.StatusNotFound) } @@ -55,6 +56,7 @@ func TestDashboardRedirect(t *testing.T) { c.Assert(err, qt.IsNil) hnd.ServeHTTP(rr, req) resp := rr.Result() + defer resp.Body.Close() c.Check(resp.StatusCode, qt.Equals, http.StatusPermanentRedirect) c.Check(resp.Header.Get("Location"), qt.Equals, "https://example.com/dashboard") } @@ -68,6 +70,7 @@ func TestInvalidLocation(t *testing.T) { c.Assert(err, qt.IsNil) hnd.ServeHTTP(rr, req) resp := rr.Result() + defer resp.Body.Close() c.Check(resp.StatusCode, qt.Equals, http.StatusNotFound) } @@ -75,7 +78,7 @@ func TestLocationNotDirectory(t *testing.T) { c := qt.New(t) dir := c.TempDir() - err := os.WriteFile(filepath.Join(dir, "test"), []byte(testFile), 0444) + err := os.WriteFile(filepath.Join(dir, "test"), []byte(testFile), 0600) c.Assert(err, qt.Equals, nil) hnd := dashboard.Handler(context.Background(), filepath.Join(dir, "test"), "http://jimm.canonical.com") @@ -84,5 +87,6 @@ func TestLocationNotDirectory(t *testing.T) { c.Assert(err, qt.IsNil) hnd.ServeHTTP(rr, req) resp := rr.Result() + defer resp.Body.Close() c.Check(resp.StatusCode, qt.Equals, http.StatusNotFound) } diff --git a/internal/db/applicationoffer.go b/internal/db/applicationoffer.go index 17995698d..3f5312436 100644 --- a/internal/db/applicationoffer.go +++ b/internal/db/applicationoffer.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package db @@ -48,9 +48,18 @@ func (d *Database) UpdateApplicationOffer(ctx context.Context, offer *dbmodel.Ap db := d.DB.WithContext(ctx) err = db.Transaction(func(tx *gorm.DB) error { tx.Omit("Connections", "Endpoints", "Spaces").Save(offer) - tx.Model(offer).Association("Connections").Replace(offer.Connections) - tx.Model(offer).Association("Endpoints").Replace(offer.Endpoints) - tx.Model(offer).Association("Spaces").Replace(offer.Spaces) + err = tx.Model(offer).Association("Connections").Replace(offer.Connections) + if err != nil { + return err + } + err = tx.Model(offer).Association("Endpoints").Replace(offer.Endpoints) + if err != nil { + return err + } + err = tx.Model(offer).Association("Spaces").Replace(offer.Spaces) + if err != nil { + return err + } return tx.Error }) if err != nil { @@ -78,11 +87,12 @@ func (d *Database) GetApplicationOffer(ctx context.Context, offer *dbmodel.Appli defer servermon.ErrorCounter(servermon.DBQueryErrorCount, &err, string(op)) db := d.DB.WithContext(ctx) - if offer.UUID != "" { + switch { + case offer.UUID != "": db = db.Where("uuid = ?", offer.UUID) - } else if offer.URL != "" { + case offer.URL != "": db = db.Where("url = ?", offer.URL) - } else { + default: return errors.E(op, "missing offer UUID or URL") } db = db.Preload("Connections") diff --git a/internal/db/applicationoffer_test.go b/internal/db/applicationoffer_test.go index a3e20f46d..341631d3e 100644 --- a/internal/db/applicationoffer_test.go +++ b/internal/db/applicationoffer_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package db_test diff --git a/internal/db/audit.go b/internal/db/audit.go index 921008357..405273ff5 100644 --- a/internal/db/audit.go +++ b/internal/db/audit.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package db diff --git a/internal/db/auditlog_test.go b/internal/db/auditlog_test.go index a7445a007..8bad93484 100644 --- a/internal/db/auditlog_test.go +++ b/internal/db/auditlog_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package db_test diff --git a/internal/db/cloud.go b/internal/db/cloud.go index 726880eeb..59a03c586 100644 --- a/internal/db/cloud.go +++ b/internal/db/cloud.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package db diff --git a/internal/db/cloud_test.go b/internal/db/cloud_test.go index 7e5e3befc..efa8ed950 100644 --- a/internal/db/cloud_test.go +++ b/internal/db/cloud_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package db_test @@ -369,6 +369,7 @@ controllers: Name: "test-cloud-1", } err = s.Database.GetCloud(ctx, &cl) + c.Assert(err, qt.IsNil) crp = cl.Regions[0].Controllers[0] diff --git a/internal/db/cloudcredential.go b/internal/db/cloudcredential.go index d58651bb2..b72c0766f 100644 --- a/internal/db/cloudcredential.go +++ b/internal/db/cloudcredential.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package db diff --git a/internal/db/cloudcredential_test.go b/internal/db/cloudcredential_test.go index 51d00f905..95569e46a 100644 --- a/internal/db/cloudcredential_test.go +++ b/internal/db/cloudcredential_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package db_test @@ -207,6 +207,7 @@ func TestForEachCloudCredentialUnconfiguredDatabase(t *testing.T) { c.Check(errors.ErrorCode(err), qt.Equals, errors.CodeServerConfiguration) } +//nolint:gosec // Thinks hardcoded credentials. const forEachCloudCredentialEnv = `clouds: - name: cloud-1 regions: diff --git a/internal/db/clouddefaults.go b/internal/db/clouddefaults.go index b423f5ae4..2e826b4bc 100644 --- a/internal/db/clouddefaults.go +++ b/internal/db/clouddefaults.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package db diff --git a/internal/db/clouddefaults_test.go b/internal/db/clouddefaults_test.go index 8178190e4..f92d5c5ca 100644 --- a/internal/db/clouddefaults_test.go +++ b/internal/db/clouddefaults_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package db_test diff --git a/internal/db/controller.go b/internal/db/controller.go index 7275fd39c..fe4393afe 100644 --- a/internal/db/controller.go +++ b/internal/db/controller.go @@ -1,14 +1,15 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package db import ( "context" + "gorm.io/gorm/clause" + "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/errors" "github.com/canonical/jimm/v3/internal/servermon" - "gorm.io/gorm/clause" ) // AddController stores the controller information. diff --git a/internal/db/controller_test.go b/internal/db/controller_test.go index 84141766b..f34d0eecd 100644 --- a/internal/db/controller_test.go +++ b/internal/db/controller_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package db_test diff --git a/internal/db/db.go b/internal/db/db.go index 1ce9b6640..d06c46e1e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. // Package db contains routines to store and retrieve data from a database. package db diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 29de79e15..4901dcdd5 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package db_test diff --git a/internal/db/errors.go b/internal/db/errors.go index 770baba96..d30a0c1ee 100644 --- a/internal/db/errors.go +++ b/internal/db/errors.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package db @@ -21,8 +21,8 @@ func dbError(err error) error { if err == gorm.ErrRecordNotFound { code = errors.CodeNotFound } - switch e := err.(type) { - case *pgconn.PgError: + + if e, ok := err.(*pgconn.PgError); ok { if e.Code == pgUniqueViolation { code = errors.CodeAlreadyExists } diff --git a/internal/db/export_test.go b/internal/db/export_test.go index 7d8555cc3..d3a632218 100644 --- a/internal/db/export_test.go +++ b/internal/db/export_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package db diff --git a/internal/db/group.go b/internal/db/group.go index ec62f18f2..d9526f865 100644 --- a/internal/db/group.go +++ b/internal/db/group.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package db @@ -12,9 +12,7 @@ import ( "github.com/canonical/jimm/v3/internal/servermon" ) -var newUUID = func() string { - return uuid.NewString() -} +var newUUID = uuid.NewString // AddGroup adds a new group. func (d *Database) AddGroup(ctx context.Context, name string) (ge *dbmodel.GroupEntry, err error) { diff --git a/internal/db/group_test.go b/internal/db/group_test.go index ccc217dae..275aa57ae 100644 --- a/internal/db/group_test.go +++ b/internal/db/group_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package db_test diff --git a/internal/db/identity.go b/internal/db/identity.go index 591689703..a1f68f6dc 100644 --- a/internal/db/identity.go +++ b/internal/db/identity.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package db diff --git a/internal/db/identity_test.go b/internal/db/identity_test.go index f0c23d91e..7ced4221f 100644 --- a/internal/db/identity_test.go +++ b/internal/db/identity_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package db_test @@ -143,6 +143,7 @@ func (s *dbSuite) TestGetIdentityCloudCredentials(c *qt.C) { c.Check(errors.ErrorCode(err), qt.Equals, errors.CodeNotFound) i, err = dbmodel.NewIdentity("test") + c.Assert(err, qt.IsNil) _, err = s.Database.GetIdentityCloudCredentials(ctx, i, "ec2") c.Check(err, qt.IsNil) diff --git a/internal/db/identitymodeldefaults.go b/internal/db/identitymodeldefaults.go index 6d7032bb0..6ffc6e7df 100644 --- a/internal/db/identitymodeldefaults.go +++ b/internal/db/identitymodeldefaults.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package db diff --git a/internal/db/identitymodeldefaults_test.go b/internal/db/identitymodeldefaults_test.go index 6ea24de5f..2c9422a6f 100644 --- a/internal/db/identitymodeldefaults_test.go +++ b/internal/db/identitymodeldefaults_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package db_test @@ -66,7 +66,7 @@ func TestSetIdentityModelDefaults(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(j.Database.DB.Create(i).Error, qt.IsNil) - j.Database.SetIdentityModelDefaults(ctx, &dbmodel.IdentityModelDefaults{ + err = j.Database.SetIdentityModelDefaults(ctx, &dbmodel.IdentityModelDefaults{ IdentityName: i.Name, Identity: *i, Defaults: map[string]interface{}{ @@ -74,6 +74,7 @@ func TestSetIdentityModelDefaults(t *testing.T) { "key2": "a test string", }, }) + c.Assert(err, qt.IsNil) defaults := map[string]interface{}{ "key1": float64(42), diff --git a/internal/db/model.go b/internal/db/model.go index 134835481..9053c6ee0 100644 --- a/internal/db/model.go +++ b/internal/db/model.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package db @@ -46,19 +46,20 @@ func (d *Database) GetModel(ctx context.Context, model *dbmodel.Model) (err erro defer servermon.ErrorCounter(servermon.DBQueryErrorCount, &err, string(op)) db := d.DB.WithContext(ctx) - if model.UUID.Valid { + switch { + case model.UUID.Valid: db = db.Where("uuid = ?", model.UUID.String) if model.ControllerID != 0 { db = db.Where("controller_id = ?", model.ControllerID) } - } else if model.ID != 0 { + case model.ID != 0: db = db.Where("id = ?", model.ID) - } else if model.OwnerIdentityName != "" && model.Name != "" { + case model.OwnerIdentityName != "" && model.Name != "": db = db.Where("owner_identity_name = ? AND name = ?", model.OwnerIdentityName, model.Name) - } else if model.ControllerID != 0 { - // TODO(ales): fix ordering of where fields and handle error to represent what is *actually* required. + case model.ControllerID != 0: + // TODO: fix ordering of where fields and handle error to represent what is *actually* required. db = db.Where("controller_id = ?", model.ControllerID) - } else { + default: return errors.E(op, "missing id or uuid", errors.CodeBadRequest) } diff --git a/internal/db/model_test.go b/internal/db/model_test.go index 9becabc4a..ae769b350 100644 --- a/internal/db/model_test.go +++ b/internal/db/model_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package db_test diff --git a/internal/db/pgx_test.go b/internal/db/pgx_test.go index fd1d2b292..7ddaadd36 100644 --- a/internal/db/pgx_test.go +++ b/internal/db/pgx_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package db_test diff --git a/internal/db/rootkeys.go b/internal/db/rootkeys.go index 62f1b4d74..3646b7026 100644 --- a/internal/db/rootkeys.go +++ b/internal/db/rootkeys.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package db diff --git a/internal/db/rootkeys_test.go b/internal/db/rootkeys_test.go index d35060cba..28909a999 100644 --- a/internal/db/rootkeys_test.go +++ b/internal/db/rootkeys_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package db_test @@ -55,7 +55,8 @@ func (s *dbSuite) TestInsertKeyGetKey(c *qt.C) { RootKey: []byte("very secret"), } - s.Database.InsertKey(rk) + err = s.Database.InsertKey(rk) + c.Assert(err, qt.IsNil) rk2, err := s.Database.GetKey([]byte("test-id")) c.Assert(err, qt.IsNil) diff --git a/internal/db/secrets.go b/internal/db/secrets.go index f9b8caeed..7d73b1ccf 100644 --- a/internal/db/secrets.go +++ b/internal/db/secrets.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package db @@ -7,14 +7,15 @@ import ( "encoding/json" "time" - "github.com/canonical/jimm/v3/internal/dbmodel" - "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/servermon" "github.com/juju/names/v5" "github.com/juju/zaputil/zapctx" "github.com/lestrrat-go/jwx/v2/jwk" "go.uber.org/zap" "gorm.io/gorm/clause" + + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/servermon" ) const ( @@ -23,12 +24,13 @@ const ( passwordKey = "password" // These constants are used to create the appropriate identifiers for JWKS related data. - jwksKind = "jwks" - jwksPublicKeyTag = "jwksPublicKey" - jwksPrivateKeyTag = "jwksPrivateKey" - jwksExpiryTag = "jwksExpiry" - oauthKind = "oauth" - oauthKeyTag = "oauthKey" + jwksKind = "jwks" + jwksPublicKeyTag = "jwksPublicKey" + jwksPrivateKeyTag = "jwksPrivateKey" + jwksExpiryTag = "jwksExpiry" + oauthKind = "oauth" + oauthKeyTag = "oauthKey" + //nolint:gosec // Thinks credentials hardcoded. oauthSessionStoreSecretTag = "oauthSessionStoreSecret" ) diff --git a/internal/db/secrets_test.go b/internal/db/secrets_test.go index f771d3be8..38baa2776 100644 --- a/internal/db/secrets_test.go +++ b/internal/db/secrets_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package db_test diff --git a/internal/dbmodel/applicationoffer.go b/internal/dbmodel/applicationoffer.go index 2fbedeb32..7a18ffcfd 100644 --- a/internal/dbmodel/applicationoffer.go +++ b/internal/dbmodel/applicationoffer.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel diff --git a/internal/dbmodel/applicationoffer_test.go b/internal/dbmodel/applicationoffer_test.go index cf02158b5..c4d2b5aaf 100644 --- a/internal/dbmodel/applicationoffer_test.go +++ b/internal/dbmodel/applicationoffer_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel_test diff --git a/internal/dbmodel/audit.go b/internal/dbmodel/audit.go index 963cb8957..cb7436453 100644 --- a/internal/dbmodel/audit.go +++ b/internal/dbmodel/audit.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel diff --git a/internal/dbmodel/audit_test.go b/internal/dbmodel/audit_test.go index 7f1c8fd90..f5a612b9a 100644 --- a/internal/dbmodel/audit_test.go +++ b/internal/dbmodel/audit_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel_test diff --git a/internal/dbmodel/cloud.go b/internal/dbmodel/cloud.go index 879238be2..a35c8a385 100644 --- a/internal/dbmodel/cloud.go +++ b/internal/dbmodel/cloud.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel diff --git a/internal/dbmodel/cloud_test.go b/internal/dbmodel/cloud_test.go index 68a9e4562..ed1291b30 100644 --- a/internal/dbmodel/cloud_test.go +++ b/internal/dbmodel/cloud_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel_test diff --git a/internal/dbmodel/cloudcredential.go b/internal/dbmodel/cloudcredential.go index 0ec0053ea..5c70e7347 100644 --- a/internal/dbmodel/cloudcredential.go +++ b/internal/dbmodel/cloudcredential.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel diff --git a/internal/dbmodel/cloudcredential_test.go b/internal/dbmodel/cloudcredential_test.go index 326b97eb3..560c7884a 100644 --- a/internal/dbmodel/cloudcredential_test.go +++ b/internal/dbmodel/cloudcredential_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel_test diff --git a/internal/dbmodel/clouddefaults.go b/internal/dbmodel/clouddefaults.go index 3c52814b9..0098f3516 100644 --- a/internal/dbmodel/clouddefaults.go +++ b/internal/dbmodel/clouddefaults.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel diff --git a/internal/dbmodel/controller.go b/internal/dbmodel/controller.go index e6800313c..49a44d96c 100644 --- a/internal/dbmodel/controller.go +++ b/internal/dbmodel/controller.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel @@ -124,16 +124,17 @@ func (c Controller) ToAPIControllerInfo() apiparams.ControllerInfo { ci.CloudRegion = c.CloudRegion ci.Username = c.AdminIdentityName ci.AgentVersion = c.AgentVersion - if c.UnavailableSince.Valid { + switch { + case c.UnavailableSince.Valid: ci.Status = jujuparams.EntityStatus{ Status: "unavailable", Since: &c.UnavailableSince.Time, } - } else if c.Deprecated { + case c.Deprecated: ci.Status = jujuparams.EntityStatus{ Status: "deprecated", } - } else { + default: ci.Status = jujuparams.EntityStatus{ Status: "available", } diff --git a/internal/dbmodel/controller_test.go b/internal/dbmodel/controller_test.go index 3a191c621..8e3d92e83 100644 --- a/internal/dbmodel/controller_test.go +++ b/internal/dbmodel/controller_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel_test diff --git a/internal/dbmodel/gorm_test.go b/internal/dbmodel/gorm_test.go index 73338a747..7de2259b8 100644 --- a/internal/dbmodel/gorm_test.go +++ b/internal/dbmodel/gorm_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel_test @@ -18,6 +18,9 @@ import ( // migrations for those objects. func gormDB(t testing.TB) *gorm.DB { database := db.Database{DB: jimmtest.PostgresDB(t, nil)} - database.Migrate(context.Background(), false) + err := database.Migrate(context.Background(), false) + if err != nil { + t.Fail() + } return database.DB } diff --git a/internal/dbmodel/group.go b/internal/dbmodel/group.go index 775740e50..5b2d7c386 100644 --- a/internal/dbmodel/group.go +++ b/internal/dbmodel/group.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel diff --git a/internal/dbmodel/group_test.go b/internal/dbmodel/group_test.go index 908b44079..e9b12b849 100644 --- a/internal/dbmodel/group_test.go +++ b/internal/dbmodel/group_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel_test diff --git a/internal/dbmodel/identity.go b/internal/dbmodel/identity.go index 5fecb14ae..fc121c04a 100644 --- a/internal/dbmodel/identity.go +++ b/internal/dbmodel/identity.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel @@ -106,7 +106,7 @@ func (i Identity) ToJujuUserInfo() jujuparams.UserInfo { var ui jujuparams.UserInfo ui.Username = i.Name ui.DisplayName = i.DisplayName - ui.Access = "" //TODO(Kian) CSS-6040 Handle merging OpenFGA and Postgres information + ui.Access = "" // TODO(Kian) CSS-6040 Handle merging OpenFGA and Postgres information ui.DateCreated = i.CreatedAt if i.LastLogin.Valid { ui.LastConnection = &i.LastLogin.Time diff --git a/internal/dbmodel/identity_test.go b/internal/dbmodel/identity_test.go index 3ab4d7b71..d4d50badb 100644 --- a/internal/dbmodel/identity_test.go +++ b/internal/dbmodel/identity_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel_test diff --git a/internal/dbmodel/identitymodeldefaults.go b/internal/dbmodel/identitymodeldefaults.go index 04c966840..32d721750 100644 --- a/internal/dbmodel/identitymodeldefaults.go +++ b/internal/dbmodel/identitymodeldefaults.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel diff --git a/internal/dbmodel/model.go b/internal/dbmodel/model.go index 07f338ec5..73f7fb505 100644 --- a/internal/dbmodel/model.go +++ b/internal/dbmodel/model.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel diff --git a/internal/dbmodel/model_test.go b/internal/dbmodel/model_test.go index e7a362025..536654154 100644 --- a/internal/dbmodel/model_test.go +++ b/internal/dbmodel/model_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel_test diff --git a/internal/dbmodel/openfga_stores.go b/internal/dbmodel/openfga_stores.go index 0c28700c8..040810ac0 100644 --- a/internal/dbmodel/openfga_stores.go +++ b/internal/dbmodel/openfga_stores.go @@ -1 +1,2 @@ +// Copyright 2024 Canonical. package dbmodel diff --git a/internal/dbmodel/rootkey.go b/internal/dbmodel/rootkey.go index b0eac5809..a5555e4ae 100644 --- a/internal/dbmodel/rootkey.go +++ b/internal/dbmodel/rootkey.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel diff --git a/internal/dbmodel/secrets.go b/internal/dbmodel/secrets.go index 02acf9d1e..1e3dd8f46 100644 --- a/internal/dbmodel/secrets.go +++ b/internal/dbmodel/secrets.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package dbmodel import "time" diff --git a/internal/dbmodel/sql.go b/internal/dbmodel/sql.go index 829339229..351a2201a 100644 --- a/internal/dbmodel/sql.go +++ b/internal/dbmodel/sql.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel diff --git a/internal/dbmodel/types.go b/internal/dbmodel/types.go index 0de054f77..ed307232e 100644 --- a/internal/dbmodel/types.go +++ b/internal/dbmodel/types.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel diff --git a/internal/dbmodel/types_test.go b/internal/dbmodel/types_test.go index b6483b56e..8c2ba558e 100644 --- a/internal/dbmodel/types_test.go +++ b/internal/dbmodel/types_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel_test diff --git a/internal/dbmodel/version.go b/internal/dbmodel/version.go index cecc92215..e1c686ae6 100644 --- a/internal/dbmodel/version.go +++ b/internal/dbmodel/version.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. // Package dbmodel contains the model objects for the relational storage // database. diff --git a/internal/dbmodel/version_test.go b/internal/dbmodel/version_test.go index 217c57c7f..5552c52f6 100644 --- a/internal/dbmodel/version_test.go +++ b/internal/dbmodel/version_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package dbmodel_test diff --git a/internal/debugapi/api.go b/internal/debugapi/api.go index d0d8e81f9..458670a62 100644 --- a/internal/debugapi/api.go +++ b/internal/debugapi/api.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package debugapi import ( diff --git a/internal/debugapi/api_test.go b/internal/debugapi/api_test.go index ae13664d0..d638ebef1 100644 --- a/internal/debugapi/api_test.go +++ b/internal/debugapi/api_test.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package debugapi_test import ( @@ -39,6 +40,7 @@ func TestDebugInfo(t *testing.T) { rr := setupHandlerAndRecorder(c, debugapi.ServerStartTime, "/info") resp := rr.Result() + defer resp.Body.Close() c.Check(resp.StatusCode, qt.Equals, http.StatusOK) buf, err := io.ReadAll(resp.Body) c.Assert(err, qt.IsNil) @@ -54,6 +56,7 @@ func TestDebugStatus(t *testing.T) { rr := setupHandlerAndRecorder(c, debugapi.ServerStartTime, "/status") resp := rr.Result() + defer resp.Body.Close() c.Check(resp.StatusCode, qt.Equals, http.StatusOK) buf, err := io.ReadAll(resp.Body) c.Assert(err, qt.IsNil) @@ -76,6 +79,7 @@ func TestDebugStatusStatusError(t *testing.T) { }), "/status") resp := rr.Result() + defer resp.Body.Close() c.Check(resp.StatusCode, qt.Equals, http.StatusOK) buf, err := io.ReadAll(resp.Body) c.Assert(err, qt.IsNil) diff --git a/internal/discharger/discharger.go b/internal/discharger/discharger.go index d06a529d9..1bd9e42be 100644 --- a/internal/discharger/discharger.go +++ b/internal/discharger/discharger.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package discharger @@ -76,11 +76,11 @@ type MacaroonDischarger struct { } // GetDischargerMux returns a mux that can handle macaroon bakery requests for the provided discharger. -func GetDischargerMux(MacaroonDischarger *MacaroonDischarger, rootPath string) *http.ServeMux { +func GetDischargerMux(macaroonDischarger *MacaroonDischarger, rootPath string) *http.ServeMux { discharger := httpbakery.NewDischarger( httpbakery.DischargerParams{ - Key: &MacaroonDischarger.kp, - Checker: httpbakery.ThirdPartyCaveatCheckerFunc(MacaroonDischarger.CheckThirdPartyCaveat), + Key: &macaroonDischarger.kp, + Checker: httpbakery.ThirdPartyCaveatCheckerFunc(macaroonDischarger.CheckThirdPartyCaveat), }, ) dischargeMux := http.NewServeMux() diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 811be24d3..26f4d57bc 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. // Package errors contains types to help handle errors in the system. package errors @@ -122,7 +122,7 @@ const ( CodeStillAlive Code = apiparams.CodeStillAlive CodeUnauthorized Code = jujuparams.CodeUnauthorized CodeUpgradeInProgress Code = jujuparams.CodeUpgradeInProgress - CodeFailedToParseTupleKey Code = "failed to parse tuple object key" + CodeFailedToParseTupleKey Code = "failed to parse tuple" CodeFailedToResolveTupleResource Code = "failed resolve resource" CodeOpenFGARequestFailed Code = "failed request to OpenFGA" CodeJWKSRetrievalFailed Code = "jwks retrieval failure" diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index 34c51152c..793903b77 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package errors_test @@ -13,7 +13,9 @@ import ( func TestEEmptyArguments(t *testing.T) { c := qt.New(t) - c.Assert(func() { errors.E() }, qt.PanicMatches, `call to errors.E with no arguments`) + c.Assert(func() { + _ = errors.E() + }, qt.PanicMatches, `call to errors.E with no arguments`) } func TestEUnknownType(t *testing.T) { diff --git a/internal/jimm/access.go b/internal/jimm/access.go index 8d9c933d7..bf3d20940 100644 --- a/internal/jimm/access.go +++ b/internal/jimm/access.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimm @@ -10,11 +10,10 @@ import ( "strings" "sync" + "github.com/canonical/ofga" "github.com/google/uuid" - "github.com/juju/juju/core/crossmodel" jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" - "github.com/juju/zaputil" "github.com/juju/zaputil/zapctx" "go.uber.org/zap" @@ -34,28 +33,23 @@ const ( var ( // Matches juju uris, jimm user/group tags and UUIDs - // Performs a single match and breaks the juju URI into 10 groups, each successive group is XORD to ensure we can run - // this just once. - // The groups are as so: + // Performs a single match and breaks the juju URI into 4 groups. + // The groups are: // [0] - Entire match // [1] - tag - // [2] - A single "-", ignored - // [3] - Controller name OR user name OR group name - // [4] - A single ":", ignored - // [5] - Controller user / model owner - // [6] - A single "/", ignored - // [7] - Model name - // [8] - A single ".", ignored - // [9] - Application offer name - // [10] - Relation specifier (i.e., #member) + // [2] - trailer (i.e. resource identifier) + // [3] - Relation specifier (i.e., #member) // A complete matcher example would look like so with square-brackets denoting groups and paranthsis denoting index: - // (1)[controller](2)[-](3)[controller-1](4)[:](5)[alice@canonical.com-place](6)[/](7)[model-1](8)[.](9)[offer-1](10)[#relation-specifier]" - // In the case of something like: user-alice@wonderland or group-alices-wonderland#member, it would look like so: - // (1)[user](2)[-](3)[alices@wonderland] - // (1)[group](2)[-](3)[alices-wonderland](10)[#member] - // So if a group, user, UUID, controller name comes in, it will always be index 3 for them - // and if a relation specifier is present, it will always be index 10 - jujuURIMatcher = regexp.MustCompile(`([a-zA-Z0-9]*)(\-|\z)([a-zA-Z0-9-@.]*)(\:|)([a-zA-Z0-9-@.]*)(\/|)([a-zA-Z0-9-]*)(\.|)([a-zA-Z0-9-]*)([a-zA-Z#]*|\z)\z`) + // (1)[controller][-](2)[myFavoriteController][#](3)[relation-specifier]" + // An example without a relation: `user-alice@wonderland`: + // (1)[user][-](2)[alice@wonderland] + // An example with a relaton `group-alices-wonderland#member`: + // (1)[group][-](2)[alices-wonderland][#](3)[member] + jujuURIMatcher = regexp.MustCompile(`([a-zA-Z0-9]*)(?:-)([^#]+)(?:#([a-zA-Z]+)|\z)`) + + // modelOwnerAndNameMatcher matches a string based on the + // the expected form / + modelOwnerAndNameMatcher = regexp.MustCompile(`(.+)/(.+)`) ) // ToOfferAccessString maps relation to an application offer access string. @@ -260,7 +254,7 @@ func (auth *JWTGenerator) MakeLoginToken(ctx context.Context, user *openfga.User for _, cloudRegion := range ctl.CloudRegions { clouds[cloudRegion.CloudRegion.Cloud.ResourceTag()] = true } - for cloudTag, _ := range clouds { + for cloudTag := range clouds { accessLevel, err := auth.accessChecker.GetUserCloudAccess(ctx, auth.user, cloudTag) if err != nil { zapctx.Error(ctx, "cloud access check failed", zap.Error(err)) @@ -400,9 +394,17 @@ func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs b return res, nil } + tagToString := func(kind, id string) string { + res := kind + "-" + id + if tag.Relation.String() != "" { + res += "#" + tag.Relation.String() + } + return res + } + switch tag.Kind { case names.UserTagKind: - return names.UserTagKind + "-" + tag.ID, nil + return tagToString(names.UserTagKind, tag.ID), nil case jimmnames.ServiceAccountTagKind: return jimmnames.ServiceAccountTagKind + "-" + tag.ID, nil case names.ControllerTagKind: @@ -416,11 +418,7 @@ func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs b if err != nil { return "", errors.E(err, fmt.Sprintf("failed to fetch controller information: %s", controller.UUID)) } - controllerString := names.ControllerTagKind + "-" + controller.Name - if tag.Relation.String() != "" { - controllerString = controllerString + "#" + tag.Relation.String() - } - return controllerString, nil + return tagToString(names.ControllerTagKind, controller.Name), nil case names.ModelTagKind: model := dbmodel.Model{ UUID: sql.NullString{ @@ -432,11 +430,8 @@ func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs b if err != nil { return "", errors.E(err, fmt.Sprintf("failed to fetch model information: %s", model.UUID.String)) } - modelString := names.ModelTagKind + "-" + model.Controller.Name + ":" + model.OwnerIdentityName + "/" + model.Name - if tag.Relation.String() != "" { - modelString = modelString + "#" + tag.Relation.String() - } - return modelString, nil + modelUserID := model.OwnerIdentityName + "/" + model.Name + return tagToString(names.ModelTagKind, modelUserID), nil case names.ApplicationOfferTagKind: ao := dbmodel.ApplicationOffer{ UUID: tag.ID, @@ -445,11 +440,7 @@ func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs b if err != nil { return "", errors.E(err, fmt.Sprintf("failed to fetch application offer information: %s", ao.UUID)) } - aoString := names.ApplicationOfferTagKind + "-" + ao.Model.Controller.Name + ":" + ao.Model.OwnerIdentityName + "/" + ao.Model.Name + "." + ao.Name - if tag.Relation.String() != "" { - aoString = aoString + "#" + tag.Relation.String() - } - return aoString, nil + return tagToString(names.ApplicationOfferTagKind, ao.URL), nil case jimmnames.GroupTagKind: group := dbmodel.GroupEntry{ UUID: tag.ID, @@ -458,11 +449,7 @@ func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs b if err != nil { return "", errors.E(err, fmt.Sprintf("failed to fetch group information: %s", group.UUID)) } - groupString := jimmnames.GroupTagKind + "-" + group.Name - if tag.Relation.String() != "" { - groupString = groupString + "#" + tag.Relation.String() - } - return groupString, nil + return tagToString(jimmnames.GroupTagKind, group.Name), nil case names.CloudTagKind: cloud := dbmodel.Cloud{ Name: tag.ID, @@ -471,161 +458,209 @@ func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs b if err != nil { return "", errors.E(err, fmt.Sprintf("failed to fetch cloud information: %s", cloud.Name)) } - cloudString := names.CloudTagKind + "-" + cloud.Name - if tag.Relation.String() != "" { - cloudString = cloudString + "#" + tag.Relation.String() - } - return cloudString, nil + return tagToString(names.CloudTagKind, cloud.Name), nil default: return "", errors.E(fmt.Sprintf("unexpected tag kind: %v", tag.Kind)) } } -// resolveTag resolves JIMM tag [of any kind available] (i.e., controller-mycontroller:alex@canonical.com/mymodel.myoffer) -// into a juju string tag (i.e., controller-). -// -// If the JIMM tag is aleady of juju string tag form, the transformation is left alone. -// -// In both cases though, the resource the tag pertains to is validated to exist within the database. -func resolveTag(jimmUUID string, db *db.Database, tag string) (*ofganames.Tag, error) { - ctx := context.Background() +type tagResolver struct { + resourceUUID string + trailer string + relation ofga.Relation +} + +func newTagResolver(tag string) (*tagResolver, string, error) { matches := jujuURIMatcher.FindStringSubmatch(tag) + if len(matches) != 4 { + return nil, "", errors.E("tag is not properly formatted", errors.CodeBadRequest) + } + tagKind := matches[1] resourceUUID := "" trailer := "" - // We first attempt to see if group3 is a uuid - if _, err := uuid.Parse(matches[3]); err == nil { + // We first attempt to see if group2 is a uuid + if _, err := uuid.Parse(matches[2]); err == nil { // We know it's a UUID - resourceUUID = matches[3] + resourceUUID = matches[2] } else { - // We presume it's a user or a group - trailer = matches[3] - } - - // Matchers along the way to determine segments of the string, they'll be empty - // if the match has failed - controllerName := matches[3] - userName := matches[5] - modelName := matches[7] - offerName := matches[9] - relationString := strings.TrimLeft(matches[10], "#") - relation, err := ofganames.ParseRelation(relationString) + // We presume the information the matcher needs is in the trailer + trailer = matches[2] + } + + relation, err := ofganames.ParseRelation(matches[3]) if err != nil { - return nil, errors.E("failed to parse relation", errors.CodeBadRequest) + return nil, "", errors.E("failed to parse relation", errors.CodeBadRequest) } + return &tagResolver{ + resourceUUID: resourceUUID, + trailer: trailer, + relation: relation, + }, tagKind, nil +} - switch matches[1] { - case names.UserTagKind: - zapctx.Debug( - ctx, - "Resolving JIMM tags to Juju tags for tag kind: user", - zap.String("user-name", trailer), - ) - return ofganames.ConvertTagWithRelation(names.NewUserTag(trailer), relation), nil +func (t *tagResolver) userTag(ctx context.Context) (*ofga.Entity, error) { + zapctx.Debug( + ctx, + "Resolving JIMM tags to Juju tags for tag kind: user", + zap.String("user-name", t.trailer), + ) - case jimmnames.GroupTagKind: - zapctx.Debug( - ctx, - "Resolving JIMM tags to Juju tags for tag kind: group", - zap.String("group-name", trailer), - ) - var entry dbmodel.GroupEntry - if resourceUUID != "" { - entry.UUID = resourceUUID - } else if trailer != "" { - entry.Name = trailer - } - err := db.GetGroup(ctx, &entry) - if err != nil { - return nil, errors.E(fmt.Sprintf("group %s not found", trailer)) - } - return ofganames.ConvertTagWithRelation(jimmnames.NewGroupTag(entry.UUID), relation), nil + valid := names.IsValidUser(t.trailer) + if !valid { + // TODO(ale8k): Return custom error for validation check at JujuAPI + return nil, errors.E("invalid user") + } + return ofganames.ConvertTagWithRelation(names.NewUserTag(t.trailer), t.relation), nil +} - case names.ControllerTagKind: - zapctx.Debug( - ctx, - "Resolving JIMM tags to Juju tags for tag kind: controller", - ) - controller := dbmodel.Controller{} - - if resourceUUID != "" { - controller.UUID = resourceUUID - } else if controllerName != "" { - if controllerName == jimmControllerName { - return ofganames.ConvertTagWithRelation(names.NewControllerTag(jimmUUID), relation), nil - } - controller.Name = controllerName - } +func (t *tagResolver) groupTag(ctx context.Context, db *db.Database) (*ofga.Entity, error) { + zapctx.Debug( + ctx, + "Resolving JIMM tags to Juju tags for tag kind: group", + zap.String("group-name", t.trailer), + ) + if t.resourceUUID != "" { + return ofganames.ConvertTagWithRelation(jimmnames.NewGroupTag(t.resourceUUID), t.relation), nil + } + entry := dbmodel.GroupEntry{Name: t.trailer} - // NOTE (alesstimec) Do we need to special-case the - // controller-jimm case - jimm controller does not exist - // in the database, but has a clearly defined UUID? + err := db.GetGroup(ctx, &entry) + if err != nil { + return nil, errors.E(fmt.Sprintf("group %s not found", t.trailer)) + } - err := db.GetController(ctx, &controller) - if err != nil { - return nil, errors.E("controller not found") - } - return ofganames.ConvertTagWithRelation(names.NewControllerTag(controller.UUID), relation), nil + return ofganames.ConvertTagWithRelation(entry.ResourceTag(), t.relation), nil +} - case names.ModelTagKind: - zapctx.Debug( - ctx, - "Resolving JIMM tags to Juju tags for tag kind: model", - ) - model := dbmodel.Model{} - - if resourceUUID != "" { - model.UUID = sql.NullString{String: resourceUUID, Valid: true} - } else if controllerName != "" && userName != "" && modelName != "" { - controller := dbmodel.Controller{Name: controllerName} - err := db.GetController(ctx, &controller) - if err != nil { - return nil, errors.E("controller not found") - } - model.ControllerID = controller.ID - model.OwnerIdentityName = userName - model.Name = modelName - } +func (t *tagResolver) controllerTag(ctx context.Context, jimmUUID string, db *db.Database) (*ofga.Entity, error) { + zapctx.Debug( + ctx, + "Resolving JIMM tags to Juju tags for tag kind: controller", + ) - err := db.GetModel(ctx, &model) - if err != nil { - return nil, errors.E("model not found") - } + if t.resourceUUID != "" { + return ofganames.ConvertTagWithRelation(names.NewControllerTag(t.resourceUUID), t.relation), nil + } + if t.trailer == jimmControllerName { + return ofganames.ConvertTagWithRelation(names.NewControllerTag(jimmUUID), t.relation), nil + } + controller := dbmodel.Controller{Name: t.trailer} - return ofganames.ConvertTagWithRelation(names.NewModelTag(model.UUID.String), relation), nil + err := db.GetController(ctx, &controller) + if err != nil { + return nil, errors.E("controller not found") + } + return ofganames.ConvertTagWithRelation(controller.ResourceTag(), t.relation), nil +} - case names.ApplicationOfferTagKind: - zapctx.Debug( - ctx, - "Resolving JIMM tags to Juju tags for tag kind: applicationoffer", - ) - offer := dbmodel.ApplicationOffer{} - - if resourceUUID != "" { - offer.UUID = resourceUUID - } else if controllerName != "" && userName != "" && modelName != "" && offerName != "" { - offerURL, err := crossmodel.ParseOfferURL(fmt.Sprintf("%s:%s/%s.%s", controllerName, userName, modelName, offerName)) - if err != nil { - zapctx.Debug(ctx, "failed to parse application offer url", zap.String("url", fmt.Sprintf("%s:%s/%s.%s", controllerName, userName, modelName, offerName)), zaputil.Error(err)) - return nil, errors.E("failed to parse offer url", err) - } - offer.URL = offerURL.String() - } +func (t *tagResolver) modelTag(ctx context.Context, db *db.Database) (*ofga.Entity, error) { + zapctx.Debug( + ctx, + "Resolving JIMM tags to Juju tags for tag kind: model", + ) - err := db.GetApplicationOffer(ctx, &offer) - if err != nil { - return nil, errors.E("application offer not found") - } + if t.resourceUUID != "" { + return ofganames.ConvertTagWithRelation(names.NewModelTag(t.resourceUUID), t.relation), nil + } + + model := dbmodel.Model{} + matches := modelOwnerAndNameMatcher.FindStringSubmatch(t.trailer) + if len(matches) != 3 { + return nil, errors.E("model name format incorrect, expected /") + } + model.OwnerIdentityName = matches[1] + model.Name = matches[2] - return ofganames.ConvertTagWithRelation(names.NewApplicationOfferTag(offer.UUID), relation), nil + err := db.GetModel(ctx, &model) + if err != nil { + return nil, errors.E("model not found") + } + + return ofganames.ConvertTagWithRelation(model.ResourceTag(), t.relation), nil +} + +func (t *tagResolver) applicationOfferTag(ctx context.Context, db *db.Database) (*ofga.Entity, error) { + zapctx.Debug( + ctx, + "Resolving JIMM tags to Juju tags for tag kind: applicationoffer", + ) + + if t.resourceUUID != "" { + return ofganames.ConvertTagWithRelation(names.NewApplicationOfferTag(t.resourceUUID), t.relation), nil + } + offer := dbmodel.ApplicationOffer{URL: t.trailer} + + err := db.GetApplicationOffer(ctx, &offer) + if err != nil { + return nil, errors.E("application offer not found") + } + + return ofganames.ConvertTagWithRelation(offer.ResourceTag(), t.relation), nil +} + +func (t *tagResolver) cloudTag(ctx context.Context, db *db.Database) (*ofga.Entity, error) { + zapctx.Debug( + ctx, + "Resolving JIMM tags to Juju tags for tag kind: cloud", + ) + + if t.resourceUUID != "" { + return ofganames.ConvertTagWithRelation(names.NewCloudTag(t.resourceUUID), t.relation), nil + } + cloud := dbmodel.Cloud{Name: t.trailer} + + err := db.GetCloud(ctx, &cloud) + if err != nil { + return nil, errors.E("application offer not found") + } + + return ofganames.ConvertTagWithRelation(cloud.ResourceTag(), t.relation), nil +} + +func (t *tagResolver) serviceAccountTag(ctx context.Context) (*ofga.Entity, error) { + zapctx.Debug( + ctx, + "Resolving JIMM tags to Juju tags for tag kind: serviceaccount", + zap.String("serviceaccount-name", t.trailer), + ) + if !jimmnames.IsValidServiceAccountId(t.trailer) { + // TODO(ale8k): Return custom error for validation check at JujuAPI + return nil, errors.E("invalid service account id") + } + + return ofganames.ConvertTagWithRelation(jimmnames.NewServiceAccountTag(t.trailer), t.relation), nil +} + +// resolveTag resolves JIMM tag [of any kind available] (i.e., controller-mycontroller:alex@canonical.com/mymodel.myoffer) +// into a juju string tag (i.e., controller-). +// +// If the JIMM tag is aleady of juju string tag form, the transformation is left alone. +// +// In both cases though, the resource the tag pertains to is validated to exist within the database. +func resolveTag(jimmUUID string, db *db.Database, tag string) (*ofganames.Tag, error) { + ctx := context.Background() + resolver, tagKind, err := newTagResolver(tag) + if err != nil { + return nil, errors.E(fmt.Errorf("failed to setup tag resolver: %w", err)) + } + + switch tagKind { + case names.UserTagKind: + return resolver.userTag(ctx) + case jimmnames.GroupTagKind: + return resolver.groupTag(ctx, db) + case names.ControllerTagKind: + return resolver.controllerTag(ctx, jimmUUID, db) + case names.ModelTagKind: + return resolver.modelTag(ctx, db) + case names.ApplicationOfferTagKind: + return resolver.applicationOfferTag(ctx, db) + case names.CloudTagKind: + return resolver.cloudTag(ctx, db) case jimmnames.ServiceAccountTagKind: - zapctx.Debug( - ctx, - "Resolving JIMM tags to Juju tags for tag kind: serviceaccount", - zap.String("serviceaccount-name", trailer), - ) - return ofganames.ConvertTagWithRelation(jimmnames.NewServiceAccountTag(trailer), relation), nil - } - return nil, errors.E("failed to map tag " + matches[1]) + return resolver.serviceAccountTag(ctx) + } + return nil, errors.E(errors.CodeBadRequest, fmt.Sprintf("failed to map tag, unknown kind: %s", tagKind)) } // parseAndValidateTag attempts to parse the provided key into a tag whilst additionally diff --git a/internal/jimm/access_test.go b/internal/jimm/access_test.go index 9923a4ee1..509a452ad 100644 --- a/internal/jimm/access_test.go +++ b/internal/jimm/access_test.go @@ -1,19 +1,20 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test import ( "context" "database/sql" + "fmt" "sort" "testing" "time" + "github.com/canonical/ofga" petname "github.com/dustinkirkland/golang-petname" qt "github.com/frankban/quicktest" "github.com/google/uuid" "github.com/juju/juju/core/crossmodel" - jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/juju/state" "github.com/juju/names/v5" @@ -27,30 +28,8 @@ import ( "github.com/canonical/jimm/v3/internal/openfga" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" jimmnames "github.com/canonical/jimm/v3/pkg/names" - "github.com/canonical/ofga" ) -// testAuthenticator is an authenticator implementation intended -// for testing the token generator. -type testAuthenticator struct { - username string - err error -} - -// Authenticate implements the Authenticate method of the Authenticator interface. -func (ta *testAuthenticator) Authenticate(ctx context.Context, req *jujuparams.LoginRequest) (*openfga.User, error) { - if ta.err != nil { - return nil, ta.err - } - i, err := dbmodel.NewIdentity(ta.username) - if err != nil { - return nil, err - } - return &openfga.User{ - Identity: i, - }, nil -} - // testDatabase is a database implementation intended for testing the token generator. type testDatabase struct { ctl dbmodel.Controller @@ -469,9 +448,9 @@ func TestParseAndValidateTag(t *testing.T) { err = j.Database.Migrate(ctx, false) c.Assert(err, qt.IsNil) - user, _, controller, model, _, _, _ := createTestControllerEnvironment(ctx, c, j.Database) + user, _, _, model, _, _, _ := createTestControllerEnvironment(ctx, c, j.Database) - jimmTag := "model-" + controller.Name + ":" + user.Name + "/" + model.Name + "#administrator" + jimmTag := "model-" + user.Name + "/" + model.Name + "#administrator" // JIMM tag syntax for models tag, err := j.ParseAndValidateTag(ctx, jimmTag) @@ -501,203 +480,99 @@ func TestParseAndValidateTag(t *testing.T) { c.Assert(err, qt.ErrorMatches, "unknown tag kind") } -func TestResolveJIMM(t *testing.T) { - c := qt.New(t) - ctx := context.Background() - - ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) - c.Assert(err, qt.IsNil) - - now := time.Now().UTC().Round(time.Millisecond) - j := &jimm.JIMM{ - UUID: uuid.NewString(), - Database: db.Database{ - DB: jimmtest.PostgresDB(c, func() time.Time { return now }), - }, - OpenFGAClient: ofgaClient, - } - - err = j.Database.Migrate(ctx, false) - c.Assert(err, qt.IsNil) - - jimmTag := "controller-jimm" - - jujuTag, err := jimm.ResolveTag(j.UUID, &j.Database, jimmTag) - c.Assert(err, qt.IsNil) - c.Assert(jujuTag, qt.DeepEquals, ofganames.ConvertTag(names.NewControllerTag(j.UUID))) -} - -func TestResolveTupleObjectMapsApplicationOffersUUIDs(t *testing.T) { - c := qt.New(t) - ctx := context.Background() - - ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) - c.Assert(err, qt.IsNil) - - now := time.Now().UTC().Round(time.Millisecond) - j := &jimm.JIMM{ - UUID: uuid.NewString(), - Database: db.Database{ - DB: jimmtest.PostgresDB(c, func() time.Time { return now }), - }, - OpenFGAClient: ofgaClient, - } - - err = j.Database.Migrate(ctx, false) - c.Assert(err, qt.IsNil) - - user, _, controller, model, offer, _, _ := createTestControllerEnvironment(ctx, c, j.Database) - - jimmTag := "applicationoffer-" + controller.Name + ":" + user.Name + "/" + model.Name + "." + offer.Name + "#administrator" - - jujuTag, err := jimm.ResolveTag(j.UUID, &j.Database, jimmTag) - c.Assert(err, qt.IsNil) - c.Assert(jujuTag, qt.DeepEquals, ofganames.ConvertTagWithRelation(names.NewApplicationOfferTag(offer.UUID), ofganames.AdministratorRelation)) -} - -func TestResolveTupleObjectMapsModelUUIDs(t *testing.T) { - c := qt.New(t) - ctx := context.Background() - - ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) - c.Assert(err, qt.IsNil) - - now := time.Now().UTC().Round(time.Millisecond) - j := &jimm.JIMM{ - UUID: uuid.NewString(), - Database: db.Database{ - DB: jimmtest.PostgresDB(c, func() time.Time { return now }), - }, - OpenFGAClient: ofgaClient, - } - - err = j.Database.Migrate(ctx, false) - c.Assert(err, qt.IsNil) - - user, _, controller, model, _, _, _ := createTestControllerEnvironment(ctx, c, j.Database) - - jimmTag := "model-" + controller.Name + ":" + user.Name + "/" + model.Name + "#administrator" - - tag, err := jimm.ResolveTag(j.UUID, &j.Database, jimmTag) - c.Assert(err, qt.IsNil) - c.Assert(tag, qt.DeepEquals, ofganames.ConvertTagWithRelation(names.NewModelTag(model.UUID.String), ofganames.AdministratorRelation)) -} - -func TestResolveTupleObjectMapsControllerUUIDs(t *testing.T) { - c := qt.New(t) - ctx := context.Background() - - ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) - c.Assert(err, qt.IsNil) - - now := time.Now().UTC().Round(time.Millisecond) - j := &jimm.JIMM{ - UUID: uuid.NewString(), - Database: db.Database{ - DB: jimmtest.PostgresDB(c, func() time.Time { return now }), - }, - OpenFGAClient: ofgaClient, - } - - err = j.Database.Migrate(ctx, false) - c.Assert(err, qt.IsNil) - - cloud := dbmodel.Cloud{ - Name: "test-cloud", - } - err = j.Database.AddCloud(context.Background(), &cloud) - c.Assert(err, qt.IsNil) - - uuid, _ := uuid.NewRandom() - controller := dbmodel.Controller{ - Name: "mycontroller", - UUID: uuid.String(), - CloudName: "test-cloud", - } - err = j.Database.AddController(ctx, &controller) - c.Assert(err, qt.IsNil) - - tag, err := jimm.ResolveTag(j.UUID, &j.Database, "controller-mycontroller#administrator") - c.Assert(err, qt.IsNil) - c.Assert(tag, qt.DeepEquals, ofganames.ConvertTagWithRelation(names.NewControllerTag(uuid.String()), ofganames.AdministratorRelation)) -} - -func TestResolveTupleObjectMapsGroups(t *testing.T) { +func TestResolveTags(t *testing.T) { c := qt.New(t) ctx := context.Background() - ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) - c.Assert(err, qt.IsNil) - now := time.Now().UTC().Round(time.Millisecond) j := &jimm.JIMM{ UUID: uuid.NewString(), Database: db.Database{ DB: jimmtest.PostgresDB(c, func() time.Time { return now }), }, - OpenFGAClient: ofgaClient, } - err = j.Database.Migrate(ctx, false) - c.Assert(err, qt.IsNil) - - _, err = j.Database.AddGroup(ctx, "myhandsomegroupofdigletts") - c.Assert(err, qt.IsNil) - group := &dbmodel.GroupEntry{ - Name: "myhandsomegroupofdigletts", - } - err = j.Database.GetGroup(ctx, group) - c.Assert(err, qt.IsNil) - // Test resolution via name and via UUID. - tag, err := jimm.ResolveTag(j.UUID, &j.Database, "group-"+group.Name+"#member") + err := j.Database.Migrate(ctx, false) c.Assert(err, qt.IsNil) - c.Assert(tag, qt.DeepEquals, ofganames.ConvertTagWithRelation(jimmnames.NewGroupTag(group.UUID), ofganames.MemberRelation)) - tag, err = jimm.ResolveTag(j.UUID, &j.Database, "group-"+group.UUID+"#member") - c.Assert(err, qt.IsNil) - c.Assert(tag, qt.DeepEquals, ofganames.ConvertTagWithRelation(jimmnames.NewGroupTag(group.UUID), ofganames.MemberRelation)) -} -func TestResolveTagObjectMapsUsers(t *testing.T) { - c := qt.New(t) - ctx := context.Background() + identity, group, controller, model, offer, cloud, _ := createTestControllerEnvironment(ctx, c, j.Database) - ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) - c.Assert(err, qt.IsNil) + testCases := []struct { + desc string + input string + expected *ofga.Entity + }{{ + desc: "map identity name with relation", + input: "user-" + identity.Name + "#member", + expected: ofganames.ConvertTagWithRelation(names.NewUserTag(identity.Name), ofganames.MemberRelation), + }, { + desc: "map group name with relation", + input: "group-" + group.Name + "#member", + expected: ofganames.ConvertTagWithRelation(jimmnames.NewGroupTag(group.UUID), ofganames.MemberRelation), + }, { + desc: "map group UUID", + input: "group-" + group.UUID, + expected: ofganames.ConvertTag(jimmnames.NewGroupTag(group.UUID)), + }, { + desc: "map group UUID with relation", + input: "group-" + group.UUID + "#member", + expected: ofganames.ConvertTagWithRelation(jimmnames.NewGroupTag(group.UUID), ofganames.MemberRelation), + }, { + desc: "map jimm controller", + input: "controller-" + "jimm", + expected: ofganames.ConvertTag(names.NewControllerTag(j.UUID)), + }, { + desc: "map controller", + input: "controller-" + controller.Name + "#administrator", + expected: ofganames.ConvertTagWithRelation(names.NewControllerTag(model.UUID.String), ofganames.AdministratorRelation), + }, { + desc: "map controller UUID", + input: "controller-" + controller.UUID, + expected: ofganames.ConvertTag(names.NewControllerTag(model.UUID.String)), + }, { + desc: "map model", + input: "model-" + model.OwnerIdentityName + "/" + model.Name + "#administrator", + expected: ofganames.ConvertTagWithRelation(names.NewModelTag(model.UUID.String), ofganames.AdministratorRelation), + }, { + desc: "map model UUID", + input: "model-" + model.UUID.String, + expected: ofganames.ConvertTag(names.NewModelTag(model.UUID.String)), + }, { + desc: "map offer", + input: "applicationoffer-" + offer.URL + "#administrator", + expected: ofganames.ConvertTagWithRelation(names.NewApplicationOfferTag(offer.UUID), ofganames.AdministratorRelation), + }, { + desc: "map offer UUID", + input: "applicationoffer-" + offer.UUID, + expected: ofganames.ConvertTag(names.NewApplicationOfferTag(offer.UUID)), + }, { + desc: "map cloud", + input: "cloud-" + cloud.Name + "#administrator", + expected: ofganames.ConvertTagWithRelation(names.NewCloudTag(cloud.Name), ofganames.AdministratorRelation), + }} - now := time.Now().UTC().Round(time.Millisecond) - j := &jimm.JIMM{ - UUID: uuid.NewString(), - Database: db.Database{ - DB: jimmtest.PostgresDB(c, func() time.Time { return now }), - }, - OpenFGAClient: ofgaClient, + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + jujuTag, err := jimm.ResolveTag(j.UUID, &j.Database, tC.input) + c.Assert(err, qt.IsNil) + c.Assert(jujuTag, qt.DeepEquals, tC.expected) + }) } - - err = j.Database.Migrate(ctx, false) - c.Assert(err, qt.IsNil) - - tag, err := jimm.ResolveTag(j.UUID, &j.Database, "user-alex@canonical.com-werly#member") - c.Assert(err, qt.IsNil) - c.Assert(tag, qt.DeepEquals, ofganames.ConvertTagWithRelation(names.NewUserTag("alex@canonical.com-werly"), ofganames.MemberRelation)) } func TestResolveTupleObjectHandlesErrors(t *testing.T) { c := qt.New(t) ctx := context.Background() - ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) - c.Assert(err, qt.IsNil) - now := time.Now().UTC().Round(time.Millisecond) j := &jimm.JIMM{ UUID: uuid.NewString(), Database: db.Database{ DB: jimmtest.PostgresDB(c, func() time.Time { return now }), }, - OpenFGAClient: ofgaClient, } - err = j.Database.Migrate(ctx, false) + err := j.Database.Migrate(ctx, false) c.Assert(err, qt.IsNil) _, _, controller, model, offer, _, _ := createTestControllerEnvironment(ctx, c, j.Database) @@ -711,7 +586,7 @@ func TestResolveTupleObjectHandlesErrors(t *testing.T) { // Resolves bad tuple objects in general { input: "unknowntag-blabla", - want: "failed to map tag unknowntag", + want: "failed to map tag, unknown kind: unknowntag", }, // Resolves bad groups where they do not exist { @@ -731,17 +606,27 @@ func TestResolveTupleObjectHandlesErrors(t *testing.T) { // Resolves bad models where it cannot be found on the specified controller { input: "model-" + controller.Name + ":alex/", - want: "model not found", + want: "model name format incorrect, expected /", }, // Resolves bad applicationoffers where it cannot be found on the specified controller/model combo { input: "applicationoffer-" + controller.Name + ":alex/" + model.Name + "." + offer.Name + "fluff", want: "application offer not found", }, + { + input: "abc", + want: "failed to setup tag resolver: tag is not properly formatted", + }, + { + input: "model-test-unknowncontroller-1:alice@canonical.com/test-model-1", + want: "model not found", + }, } - for _, tc := range tests { - _, err := jimm.ResolveTag(j.UUID, &j.Database, tc.input) - c.Assert(err, qt.ErrorMatches, tc.want) + for i, tc := range tests { + t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) { + _, err := jimm.ResolveTag(j.UUID, &j.Database, tc.input) + c.Assert(err, qt.ErrorMatches, tc.want) + }) } } @@ -1015,7 +900,7 @@ func TestRemoveGroupRemovesTuples(t *testing.T) { c.Assert(err, qt.IsNil) tuples := []openfga.Tuple{ - //This tuple should remain as it has no relation to group2 + // This tuple should remain as it has no relation to group2 { Object: ofganames.ConvertTag(user.ResourceTag()), Relation: "member", diff --git a/internal/jimm/admin.go b/internal/jimm/admin.go index 49fc363e4..f17ff3690 100644 --- a/internal/jimm/admin.go +++ b/internal/jimm/admin.go @@ -1,62 +1,103 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package jimm import ( "context" + "net/http" "golang.org/x/oauth2" "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/jimm/credentials" + "github.com/canonical/jimm/v3/internal/openfga" + "github.com/canonical/jimm/v3/pkg/names" ) // LoginDevice starts the device login flow. -func LoginDevice(ctx context.Context, authenticator OAuthAuthenticator) (*oauth2.DeviceAuthResponse, error) { - const op = errors.Op("jujuapi.LoginDevice") - - deviceResponse, err := authenticator.Device(ctx) +func (j *JIMM) LoginDevice(ctx context.Context) (*oauth2.DeviceAuthResponse, error) { + const op = errors.Op("jimm.LoginDevice") + resp, err := j.OAuthAuthenticator.Device(ctx) if err != nil { return nil, errors.E(op, err) } - - return deviceResponse, nil + return resp, nil } -func GetDeviceSessionToken(ctx context.Context, authenticator OAuthAuthenticator, credentialStore credentials.CredentialStore, deviceOAuthResponse *oauth2.DeviceAuthResponse) (string, error) { - const op = errors.Op("jujuapi.GetDeviceSessionToken") - - if authenticator == nil { - return "", errors.E("nil authenticator") - } +func (j *JIMM) AuthenticateBrowserSession(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, error) { + return j.OAuthAuthenticator.AuthenticateBrowserSession(ctx, w, r) +} - if credentialStore == nil { - return "", errors.E("nil credential store") - } +// GetDeviceSessionToken polls an OIDC server while a user logs in and returns a session token scoped to the user's identity. +func (j *JIMM) GetDeviceSessionToken(ctx context.Context, deviceOAuthResponse *oauth2.DeviceAuthResponse) (string, error) { + const op = errors.Op("jimm.GetDeviceSessionToken") - token, err := authenticator.DeviceAccessToken(ctx, deviceOAuthResponse) + token, err := j.OAuthAuthenticator.DeviceAccessToken(ctx, deviceOAuthResponse) if err != nil { return "", errors.E(op, err) } - idToken, err := authenticator.ExtractAndVerifyIDToken(ctx, token) + idToken, err := j.OAuthAuthenticator.ExtractAndVerifyIDToken(ctx, token) if err != nil { return "", errors.E(op, err) } - email, err := authenticator.Email(idToken) + email, err := j.OAuthAuthenticator.Email(idToken) if err != nil { return "", errors.E(op, err) } - if err := authenticator.UpdateIdentity(ctx, email, token); err != nil { + if err := j.OAuthAuthenticator.UpdateIdentity(ctx, email, token); err != nil { return "", errors.E(op, err) } - encToken, err := authenticator.MintSessionToken(email) + encToken, err := j.OAuthAuthenticator.MintSessionToken(email) if err != nil { return "", errors.E(op, err) } return string(encToken), nil } + +// LoginClientCredentials verifies a user's client ID and secret before the user is logged in. +func (j *JIMM) LoginClientCredentials(ctx context.Context, clientID string, clientSecret string) (*openfga.User, error) { + const op = errors.Op("jimm.LoginClientCredentials") + // We expect the client to send the service account ID "as-is" and because we know that this is a clientCredentials login, + // we can append the @serviceaccount domain to the clientID (if not already present). + clientIdWithDomain, err := names.EnsureValidServiceAccountId(clientID) + if err != nil { + return nil, errors.E(op, err) + } + + err = j.OAuthAuthenticator.VerifyClientCredentials(ctx, clientID, clientSecret) + if err != nil { + return nil, errors.E(op, err) + } + + return j.UserLogin(ctx, clientIdWithDomain) +} + +// LoginWithSessionToken verifies a user's session token before the user is logged in. +func (j *JIMM) LoginWithSessionToken(ctx context.Context, sessionToken string) (*openfga.User, error) { + const op = errors.Op("jimm.LoginWithSessionToken") + jwtToken, err := j.OAuthAuthenticator.VerifySessionToken(sessionToken) + if err != nil { + return nil, errors.E(op, err) + } + + email := jwtToken.Subject() + return j.UserLogin(ctx, email) +} + +// LoginWithSessionCookie uses the identity ID expected to have come from a session cookie, to log the user in. +// +// The work to parse and store the user's identity from the session cookie takes place in internal/jimmhttp/websocket.go +// [WSHandler.ServerHTTP] during the upgrade from an HTTP connection to a websocket. The user's identity is stored +// and passed to this function with the assumption that the cookie contained a valid session. This function is far from +// the session cookie logic due to the separation between the HTTP layer and Juju's RPC mechanism. +func (j *JIMM) LoginWithSessionCookie(ctx context.Context, identityID string) (*openfga.User, error) { + const op = errors.Op("jimm.LoginWithSessionCookie") + if identityID == "" { + return nil, errors.E(op, "missing cookie identity") + } + return j.UserLogin(ctx, identityID) +} diff --git a/internal/jimm/admin_test.go b/internal/jimm/admin_test.go new file mode 100644 index 000000000..c452a451e --- /dev/null +++ b/internal/jimm/admin_test.go @@ -0,0 +1,138 @@ +// Copyright 2024 Canonical. + +package jimm_test + +import ( + "context" + "encoding/base64" + "testing" + "time" + + qt "github.com/frankban/quicktest" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/lestrrat-go/jwx/v2/jwt" + "golang.org/x/oauth2" + + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/jimm" + "github.com/canonical/jimm/v3/internal/jimmtest" +) + +func TestLoginDevice(t *testing.T) { + c := qt.New(t) + mockAuthenticator := jimmtest.NewMockOAuthAuthenticator(c, nil) + jimm := jimm.JIMM{ + OAuthAuthenticator: &mockAuthenticator, + } + resp, err := jimm.LoginDevice(context.Background()) + c.Assert(err, qt.IsNil) + c.Assert(*resp, qt.CmpEquals(cmpopts.IgnoreTypes(time.Time{})), oauth2.DeviceAuthResponse{ + DeviceCode: "test-device-code", + UserCode: "test-user-code", + VerificationURI: "http://no-such-uri.canonical.com", + VerificationURIComplete: "http://no-such-uri.canonical.com", + Interval: int64(time.Minute.Seconds()), + }) +} + +func TestGetDeviceSessionToken(t *testing.T) { + c := qt.New(t) + pollingChan := make(chan string, 1) + mockAuthenticator := jimmtest.NewMockOAuthAuthenticator(c, pollingChan) + jimm := jimm.JIMM{ + OAuthAuthenticator: &mockAuthenticator, + } + pollingChan <- "user-foo" + token, err := jimm.GetDeviceSessionToken(context.Background(), nil) + c.Assert(err, qt.IsNil) + c.Assert(token, qt.Not(qt.Equals), "") + decodedToken, err := base64.StdEncoding.DecodeString(token) + c.Assert(err, qt.IsNil) + parsedToken, err := jwt.ParseInsecure([]byte(decodedToken)) + c.Assert(err, qt.IsNil) + c.Assert(parsedToken.Subject(), qt.Equals, "user-foo@canonical.com") +} + +func TestLoginClientCredentials(t *testing.T) { + c := qt.New(t) + mockAuthenticator := jimmtest.NewMockOAuthAuthenticator(c, nil) + client, _, _, err := jimmtest.SetupTestOFGAClient(c.Name(), t.Name()) + c.Assert(err, qt.IsNil) + jimm := jimm.JIMM{ + UUID: "foo", + Database: db.Database{ + DB: jimmtest.PostgresDB(c, func() time.Time { return now }), + }, + OAuthAuthenticator: &mockAuthenticator, + OpenFGAClient: client, + } + ctx := context.Background() + err = jimm.Database.Migrate(ctx, false) + c.Assert(err, qt.IsNil) + invalidClientID := "123@123@" + _, err = jimm.LoginClientCredentials(ctx, invalidClientID, "foo-secret") + c.Assert(err, qt.ErrorMatches, "invalid client ID") + + validClientID := "my-svc-acc" + user, err := jimm.LoginClientCredentials(ctx, validClientID, "foo-secret") + c.Assert(err, qt.IsNil) + c.Assert(user.Name, qt.Equals, "my-svc-acc@serviceaccount") +} + +func TestLoginWithSessionToken(t *testing.T) { + c := qt.New(t) + mockAuthenticator := jimmtest.NewMockOAuthAuthenticator(c, nil) + client, _, _, err := jimmtest.SetupTestOFGAClient(c.Name(), t.Name()) + c.Assert(err, qt.IsNil) + jimm := jimm.JIMM{ + UUID: "foo", + Database: db.Database{ + DB: jimmtest.PostgresDB(c, func() time.Time { return now }), + }, + OAuthAuthenticator: &mockAuthenticator, + OpenFGAClient: client, + } + ctx := context.Background() + err = jimm.Database.Migrate(ctx, false) + c.Assert(err, qt.IsNil) + + token, err := jwt.NewBuilder(). + Subject("alice@canonical.com"). + Build() + c.Assert(err, qt.IsNil) + serialisedToken, err := jwt.NewSerializer().Serialize(token) + c.Assert(err, qt.IsNil) + b64Token := base64.StdEncoding.EncodeToString(serialisedToken) + + _, err = jimm.LoginWithSessionToken(ctx, "invalid-token") + c.Assert(err, qt.ErrorMatches, "failed to decode token") + + user, err := jimm.LoginWithSessionToken(ctx, b64Token) + c.Assert(err, qt.IsNil) + c.Assert(user.Name, qt.Equals, "alice@canonical.com") +} + +func TestLoginWithSessionCookie(t *testing.T) { + c := qt.New(t) + mockAuthenticator := jimmtest.NewMockOAuthAuthenticator(c, nil) + client, _, _, err := jimmtest.SetupTestOFGAClient(c.Name(), t.Name()) + c.Assert(err, qt.IsNil) + jimm := jimm.JIMM{ + UUID: "foo", + Database: db.Database{ + DB: jimmtest.PostgresDB(c, func() time.Time { return now }), + }, + OAuthAuthenticator: &mockAuthenticator, + OpenFGAClient: client, + } + ctx := context.Background() + err = jimm.Database.Migrate(ctx, false) + c.Assert(err, qt.IsNil) + + _, err = jimm.LoginWithSessionCookie(ctx, "") + c.Assert(err, qt.ErrorMatches, "missing cookie identity") + + user, err := jimm.LoginWithSessionCookie(ctx, "alice@canonical.com") + c.Assert(err, qt.IsNil) + c.Assert(user.Name, qt.Equals, "alice@canonical.com") +} diff --git a/internal/jimm/applicationoffer.go b/internal/jimm/applicationoffer.go index b72388e22..004f7aaf2 100644 --- a/internal/jimm/applicationoffer.go +++ b/internal/jimm/applicationoffer.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimm @@ -75,11 +75,9 @@ func (j *JIMM) Offer(ctx context.Context, user *openfga.User, offer AddApplicati err = j.Database.GetApplicationOffer(ctx, &offerCheck) if err == nil { return errors.E(fmt.Sprintf("offer %s already exists, please use a different name", offerURL.String()), errors.CodeAlreadyExists) - } else { - if errors.ErrorCode(err) != errors.CodeNotFound { - // Anything besides Not Found is a problem. - return errors.E(op, err) - } + } else if errors.ErrorCode(err) != errors.CodeNotFound { + // Anything besides Not Found is a problem. + return errors.E(op, err) } api, err := j.dial(ctx, &model.Controller, names.ModelTag{}) @@ -168,16 +166,7 @@ func (j *JIMM) Offer(ctx context.Context, user *openfga.User, offer AddApplicati zap.String("application-offer", doc.UUID)) } - everyoneIdentity, err := dbmodel.NewIdentity(ofganames.EveryoneUser) - if err != nil { - return errors.E(op, err) - } - - everyone := openfga.NewUser( - everyoneIdentity, - j.OpenFGAClient, - ) - if err := everyone.SetApplicationOfferAccess(ctx, doc.ResourceTag(), ofganames.ReaderRelation); err != nil { + if err := j.everyoneUser().SetApplicationOfferAccess(ctx, doc.ResourceTag(), ofganames.ReaderRelation); err != nil { zapctx.Error( ctx, "failed relation between user and application offer", @@ -459,8 +448,7 @@ func (j *JIMM) RevokeOfferAccess(ctx context.Context, user *openfga.User, offerU stillHasAccess := false switch targetRelation { case ofganames.AdministratorRelation: - switch currentRelation { - case ofganames.AdministratorRelation: + if currentRelation == ofganames.AdministratorRelation { stillHasAccess = true } case ofganames.ConsumerRelation: diff --git a/internal/jimm/applicationoffer_test.go b/internal/jimm/applicationoffer_test.go index 346fd3439..5ab743ac9 100644 --- a/internal/jimm/applicationoffer_test.go +++ b/internal/jimm/applicationoffer_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test @@ -303,8 +303,10 @@ func TestRevokeOfferAccess(t *testing.T) { return env.users[1], env.users[4], env.applicationOffers[0].URL, jujuparams.OfferConsumeAccess }, setup: func(env *environment, client *openfga.OFGAClient) { - openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.ConsumerRelation) - openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.AdministratorRelation) + err := openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.ConsumerRelation) + c.Assert(err, qt.IsNil) + err = openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.AdministratorRelation) + c.Assert(err, qt.IsNil) }, expectedError: "unable to completely revoke given access due to other relations.*jimmctl.*", expectedAccessLevelOnError: "admin", @@ -314,8 +316,10 @@ func TestRevokeOfferAccess(t *testing.T) { return env.users[1], env.users[4], env.applicationOffers[0].URL, jujuparams.OfferReadAccess }, setup: func(env *environment, client *openfga.OFGAClient) { - openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.ReaderRelation) - openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.AdministratorRelation) + err := openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.ReaderRelation) + c.Assert(err, qt.IsNil) + err = openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.AdministratorRelation) + c.Assert(err, qt.IsNil) }, expectedError: "unable to completely revoke given access due to other relations.*jimmctl.*", expectedAccessLevelOnError: "admin", @@ -325,8 +329,10 @@ func TestRevokeOfferAccess(t *testing.T) { return env.users[1], env.users[4], env.applicationOffers[0].URL, jujuparams.OfferReadAccess }, setup: func(env *environment, client *openfga.OFGAClient) { - openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.ReaderRelation) - openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.ConsumerRelation) + err := openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.ReaderRelation) + c.Assert(err, qt.IsNil) + err = openfga.NewUser(&env.users[4], client).SetApplicationOfferAccess(ctx, env.applicationOffers[0].ResourceTag(), ofganames.ConsumerRelation) + c.Assert(err, qt.IsNil) }, expectedError: "unable to completely revoke given access due to other relations.*jimmctl.*", expectedAccessLevelOnError: "consume", diff --git a/internal/jimm/audit_log.go b/internal/jimm/audit_log.go index 8f2d0c0d7..7bd4d5cab 100644 --- a/internal/jimm/audit_log.go +++ b/internal/jimm/audit_log.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package jimm @@ -188,7 +188,7 @@ func calculateNextPollDuration(startingTime time.Time) time.Duration { now := startingTime nineAM := time.Date(now.Year(), now.Month(), now.Day(), pollDuration.Hours, 0, 0, 0, time.UTC) nineAMDuration := nineAM.Sub(now) - d := time.Hour + var d time.Duration // If 9am is behind the current time, i.e., 1pm if nineAMDuration < 0 { // Add 24 hours, flip it to an absolute duration, i.e., -10h == 10h diff --git a/internal/jimm/audit_log_test.go b/internal/jimm/audit_log_test.go index 219bcf2fb..98ec08fcd 100644 --- a/internal/jimm/audit_log_test.go +++ b/internal/jimm/audit_log_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test diff --git a/internal/jimm/cache.go b/internal/jimm/cache.go index f1ee70cb3..07fd93563 100644 --- a/internal/jimm/cache.go +++ b/internal/jimm/cache.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimm @@ -44,7 +44,7 @@ func (d *cacheDialer) Dial(ctx context.Context, ctl *dbmodel.Controller, mt name return d.dialer.Dial(ctx, ctl, mt, requiredPermissions) } rc := d.sfg.DoChan(ctl.Name, func() (interface{}, error) { - return d.dial(ctx, ctl) + return d.dial(ctx, ctl, requiredPermissions) }) select { case r := <-rc: @@ -57,7 +57,7 @@ func (d *cacheDialer) Dial(ctx context.Context, ctl *dbmodel.Controller, mt name } } -func (d *cacheDialer) dial(ctx context.Context, ctl *dbmodel.Controller) (interface{}, error) { +func (d *cacheDialer) dial(ctx context.Context, ctl *dbmodel.Controller, requiredPermissions map[string]string) (interface{}, error) { d.mu.Lock() capi, ok := d.conns[ctl.Name] if ok { @@ -73,7 +73,7 @@ func (d *cacheDialer) dial(ctx context.Context, ctl *dbmodel.Controller) (interf d.mu.Unlock() // We don't have a working connection to the controller, so dial one. - api, err := d.dialer.Dial(ctx, ctl, names.ModelTag{}, nil) + api, err := d.dialer.Dial(ctx, ctl, names.ModelTag{}, requiredPermissions) if err != nil { return nil, err } diff --git a/internal/jimm/cache_test.go b/internal/jimm/cache_test.go index 740ae478f..2963888fe 100644 --- a/internal/jimm/cache_test.go +++ b/internal/jimm/cache_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test diff --git a/internal/jimm/cloud.go b/internal/jimm/cloud.go index 44c27fca2..f94e0d842 100644 --- a/internal/jimm/cloud.go +++ b/internal/jimm/cloud.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimm @@ -23,18 +23,6 @@ import ( // GetUserCloudAccess returns users access level for the specified cloud. func (j *JIMM) GetUserCloudAccess(ctx context.Context, user *openfga.User, cloud names.CloudTag) (string, error) { accessLevel := user.GetCloudAccess(ctx, cloud) - if accessLevel == ofganames.NoRelation { - everyoneTag := names.NewUserTag(ofganames.EveryoneUser) - everyoneIdentity, err := dbmodel.NewIdentity(everyoneTag.Id()) - if err != nil { - return "", err - } - everyone := openfga.NewUser( - everyoneIdentity, - j.OpenFGAClient, - ) - accessLevel = everyone.GetCloudAccess(ctx, cloud) - } return ToCloudAccessString(accessLevel), nil } @@ -84,7 +72,6 @@ func (j *JIMM) ForEachUserCloud(ctx context.Context, user *openfga.User, f func( if err != nil { return errors.E(op, err, "cannot load clouds") } - seen := make(map[string]bool, len(clouds)) for _, cloud := range clouds { userAccess := ToCloudAccessString(user.GetCloudAccess(ctx, cloud.ResourceTag())) if userAccess == "" { @@ -95,30 +82,6 @@ func (j *JIMM) ForEachUserCloud(ctx context.Context, user *openfga.User, f func( if err := f(&cloud); err != nil { return err } - seen[cloud.Name] = true - } - - // Also include "public" clouds - everyoneDB, err := dbmodel.NewIdentity(ofganames.EveryoneUser) - if err != nil { - return errors.E(op, err) - } - - everyone := openfga.NewUser(everyoneDB, j.OpenFGAClient) - - for _, cloud := range clouds { - if seen[cloud.Name] { - continue - } - userAccess := ToCloudAccessString(everyone.GetCloudAccess(ctx, cloud.ResourceTag())) - if userAccess == "" { - // if user does not have access to the cloud, - // we skip this cloud - continue - } - if err := f(&cloud); err != nil { - return err - } } return nil @@ -184,106 +147,33 @@ var DefaultReservedCloudNames = []string{ func (j *JIMM) AddCloudToController(ctx context.Context, user *openfga.User, controllerName string, tag names.CloudTag, cloud jujuparams.Cloud, force bool) error { const op = errors.Op("jimm.AddCloudToController") - controller := dbmodel.Controller{ - Name: controllerName, - } - err := j.Database.GetController(ctx, &controller) - if err != nil { - return errors.E(op, errors.CodeNotFound, "controller not found") - } - - isAdministrator, err := openfga.IsAdministrator(ctx, user, controller.ResourceTag()) + controller, err := j.getControllerByName(ctx, controllerName) if err != nil { return errors.E(op, err) } - if !isAdministrator { - return errors.E(op, errors.CodeUnauthorized, "unauthorized") + if err := j.checkControllerAdminAccess(ctx, user, controller); err != nil { + return errors.E(op, err) } - // Ensure the new cloud could not mask the name of a known public cloud. - reservedNames := j.ReservedCloudNames - if len(reservedNames) == 0 { - reservedNames = DefaultReservedCloudNames - } - for _, n := range reservedNames { - if tag.Id() == n { - return errors.E(op, errors.CodeAlreadyExists, fmt.Sprintf("cloud %q already exists", tag.Id())) - } + if err := checkReservedCloudNames(tag, j.ReservedCloudNames); err != nil { + return errors.E(op, err) } - if cloud.HostCloudRegion != "" { - parts := strings.SplitN(cloud.HostCloudRegion, "/", 2) - if len(parts) != 2 || parts[0] == "" { - return errors.E(op, errors.CodeIncompatibleClouds, fmt.Sprintf("cloud host region %q has invalid cloud/region format", cloud.HostCloudRegion)) - } - region, err := j.Database.FindRegion(ctx, parts[0], parts[1]) - if err != nil { - if errors.ErrorCode(err) == errors.CodeNotFound { - return errors.E(op, err, errors.CodeIncompatibleClouds, fmt.Sprintf("unable to find cloud/region %q", cloud.HostCloudRegion)) - } - return errors.E(op, err) - } - allowedAddModel, err := user.IsAllowedAddModel(ctx, region.Cloud.ResourceTag()) - if err != nil { - return errors.E(op, err) - } - - if !allowedAddModel { - return errors.E(op, errors.CodeUnauthorized, fmt.Sprintf("missing access to %q", cloud.HostCloudRegion)) - } - - if region.Cloud.HostCloudRegion != "" { - // Do not support creating a new cloud on an already hosted - // cloud. - return errors.E(op, errors.CodeIncompatibleClouds, fmt.Sprintf("cloud already hosted %q", cloud.HostCloudRegion)) - } - - found := false - for _, rc := range region.Controllers { - if rc.Controller.Name == controllerName { - found = true - break - } - } - if !found { - return errors.E(op, errors.CodeNotFound, "controller not found") - } + if err := validateCloudRegion(ctx, &j.Database, user, cloud, controllerName); err != nil { + return errors.E(op, err) } - var dbCloud dbmodel.Cloud - dbCloud.FromJujuCloud(cloud) - dbCloud.Name = tag.Id() - - ccloud, err := j.addControllerCloud(ctx, &controller, user.ResourceTag(), tag, cloud, force) + dbCloud, err := j.addCloudToDatabase(ctx, controller, user, tag, cloud, force) if err != nil { return errors.E(op, err) } - dbCloud.FromJujuCloud(*ccloud) - for i := range dbCloud.Regions { - dbCloud.Regions[i].Controllers = []dbmodel.CloudRegionControllerPriority{{ - ControllerID: controller.ID, - Priority: dbmodel.CloudRegionControllerPrioritySupported, - }} - } - if err := j.Database.AddCloud(ctx, &dbCloud); err != nil { + // TODO(ale8k): We've added the cloud to the db, but the access failed. + // This call needs to be idempotent. + if err := j.addCloudControllerRelation(ctx, dbCloud, *controller); err != nil { return errors.E(op, err) } - - err = j.OpenFGAClient.AddCloudController(ctx, dbCloud.ResourceTag(), controller.ResourceTag()) - if err != nil { - zapctx.Error( - ctx, - "failed to add controller relation between controller and cloud", - zap.String("controller", controller.ResourceTag().Id()), - zap.String("cloud", dbCloud.ResourceTag().Id()), - zap.Error(err), - ) - } - - // TODO(Kian) CSS-6081 Give user access to the cloud here and potentially everyone. - return nil } @@ -406,25 +296,6 @@ func (j *JIMM) AddHostedCloud(ctx context.Context, user *openfga.User, tag names return nil } -func randomController() func(controllers []dbmodel.CloudRegionControllerPriority) (dbmodel.Controller, error) { - return func(controllers []dbmodel.CloudRegionControllerPriority) (dbmodel.Controller, error) { - shuffleRegionControllers(controllers) - return controllers[0].Controller, nil - } -} - -func findController(controllerName string) func(controllers []dbmodel.CloudRegionControllerPriority) (dbmodel.Controller, error) { - return func(controllers []dbmodel.CloudRegionControllerPriority) (dbmodel.Controller, error) { - for _, crp := range controllers { - crp := crp - if crp.Controller.Name == controllerName { - return crp.Controller, nil - } - } - return dbmodel.Controller{}, errors.E("controller not found", errors.CodeNotFound) - } -} - // addControllerCloud creates the hosted cloud defined by the given tag and // jujuparams cloud definition. Admin access to the cloud will be granted // to the user identified by the given user tag. On success @@ -833,3 +704,105 @@ func (j *JIMM) RemoveCloudFromController(ctx context.Context, user *openfga.User return nil } + +// addCloudControllerRelation adds a controller relation between a cloud and controller. +func (j *JIMM) addCloudControllerRelation(ctx context.Context, cloud dbmodel.Cloud, ctl dbmodel.Controller) error { + err := j.OpenFGAClient.AddCloudController(ctx, cloud.ResourceTag(), ctl.ResourceTag()) + if err != nil { + zapctx.Error( + ctx, + "failed to add controller relation between controller and cloud", + zap.String("controller", ctl.ResourceTag().Id()), + zap.String("cloud", cloud.ResourceTag().Id()), + zap.Error(err), + ) + } + return err +} + +// validateCloudRegion validates that the cloud region: +// +// - Exists +// - The user can add models using this cloud +// - The host cloud region is set +// - The controller we wish to add a cloud to is in the region +func validateCloudRegion(ctx context.Context, db *db.Database, user *openfga.User, cloud jujuparams.Cloud, controllerName string) error { + if cloud.HostCloudRegion == "" { + return nil + } + + parts := strings.SplitN(cloud.HostCloudRegion, "/", 2) + if len(parts) != 2 || parts[0] == "" { + return errors.E(errors.CodeIncompatibleClouds, fmt.Sprintf("cloud host region %q has invalid cloud/region format", cloud.HostCloudRegion)) + } + + region, err := db.FindRegion(ctx, parts[0], parts[1]) + if err != nil { + if errors.ErrorCode(err) == errors.CodeNotFound { + return errors.E(errors.CodeIncompatibleClouds, fmt.Sprintf("unable to find cloud/region %q", cloud.HostCloudRegion)) + } + return err + } + + allowedAddModel, err := user.IsAllowedAddModel(ctx, region.Cloud.ResourceTag()) + if err != nil { + return err + } + if !allowedAddModel { + return errors.E(errors.CodeUnauthorized, fmt.Sprintf("missing access to %q", cloud.HostCloudRegion)) + } + + if region.Cloud.HostCloudRegion != "" { + return errors.E(errors.CodeIncompatibleClouds, fmt.Sprintf("cloud already hosted %q", cloud.HostCloudRegion)) + } + + for _, rc := range region.Controllers { + if rc.Controller.Name == controllerName { + return nil + } + } + return errors.E(errors.CodeNotFound, "controller not found") +} + +// checkReservedCloudNames checks if the tag intended to be added to JIMM +// is a reserved name. +func checkReservedCloudNames(tag names.CloudTag, reservedCloudNames []string) error { + reservedNames := reservedCloudNames + if len(reservedNames) == 0 { + reservedNames = DefaultReservedCloudNames + } + for _, n := range reservedNames { + if tag.Id() == n { + return errors.E(errors.CodeAlreadyExists, fmt.Sprintf("cloud %q already exists", tag.Id())) + } + } + return nil +} + +// addCloudToDatabase adds the cloud to the database for this controller. +// Additionally, it sets the cloud to controller access relation. +func (j *JIMM) addCloudToDatabase(ctx context.Context, controller *dbmodel.Controller, user *openfga.User, tag names.CloudTag, cloud jujuparams.Cloud, force bool) (dbmodel.Cloud, error) { + const op = errors.Op("jimm.addCloudToDatabase") + + var dbCloud dbmodel.Cloud + dbCloud.FromJujuCloud(cloud) + dbCloud.Name = tag.Id() + + ccloud, err := j.addControllerCloud(ctx, controller, user.ResourceTag(), tag, cloud, force) + if err != nil { + return dbCloud, errors.E(op, err) + } + + dbCloud.FromJujuCloud(*ccloud) + for i := range dbCloud.Regions { + dbCloud.Regions[i].Controllers = []dbmodel.CloudRegionControllerPriority{{ + ControllerID: controller.ID, + Priority: dbmodel.CloudRegionControllerPrioritySupported, + }} + } + if err := j.Database.AddCloud(ctx, &dbCloud); err != nil { + return dbCloud, errors.E(op, err) + } + + return dbCloud, nil +} diff --git a/internal/jimm/cloud_test.go b/internal/jimm/cloud_test.go index 669c86f1c..7b21b7086 100644 --- a/internal/jimm/cloud_test.go +++ b/internal/jimm/cloud_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test @@ -75,13 +75,6 @@ func TestGetCloud(t *testing.T) { ) c.Assert(err, qt.IsNil) - everyoneIdentity, err := dbmodel.NewIdentity(ofganames.EveryoneUser) - c.Assert(err, qt.IsNil) - everyone := openfga.NewUser( - everyoneIdentity, - client, - ) - cloud := &dbmodel.Cloud{ Name: "test-cloud-1", } @@ -106,7 +99,7 @@ func TestGetCloud(t *testing.T) { err = client.AddCloudController(context.Background(), cloud2.ResourceTag(), j.ResourceTag()) c.Assert(err, qt.IsNil) - err = everyone.SetCloudAccess(context.Background(), cloud2.ResourceTag(), ofganames.CanAddModelRelation) + err = j.EveryoneUser().SetCloudAccess(context.Background(), cloud2.ResourceTag(), ofganames.CanAddModelRelation) c.Assert(err, qt.IsNil) _, err = j.GetCloud(ctx, alice, names.NewCloudTag("test-cloud-0")) @@ -204,13 +197,6 @@ func TestForEachCloud(t *testing.T) { ) daphne.JimmAdmin = true - everyoneIdentity, err := dbmodel.NewIdentity(ofganames.EveryoneUser) - c.Assert(err, qt.IsNil) - everyone := openfga.NewUser( - everyoneIdentity, - client, - ) - cloud := &dbmodel.Cloud{ Name: "test-cloud-1", } @@ -230,7 +216,7 @@ func TestForEachCloud(t *testing.T) { err = bob.SetCloudAccess(ctx, cloud2.ResourceTag(), ofganames.CanAddModelRelation) c.Assert(err, qt.IsNil) - err = everyone.SetCloudAccess(ctx, cloud2.ResourceTag(), ofganames.CanAddModelRelation) + err = j.EveryoneUser().SetCloudAccess(ctx, cloud2.ResourceTag(), ofganames.CanAddModelRelation) c.Assert(err, qt.IsNil) cloud3 := &dbmodel.Cloud{ @@ -239,7 +225,7 @@ func TestForEachCloud(t *testing.T) { err = j.Database.AddCloud(ctx, cloud3) c.Assert(err, qt.IsNil) - err = everyone.SetCloudAccess(ctx, cloud3.ResourceTag(), ofganames.CanAddModelRelation) + err = j.EveryoneUser().SetCloudAccess(ctx, cloud3.ResourceTag(), ofganames.CanAddModelRelation) c.Assert(err, qt.IsNil) var clds []dbmodel.Cloud @@ -1061,6 +1047,7 @@ func TestGrantCloudAccess(t *testing.T) { Err: tt.dialError, } j := &jimm.JIMM{ + UUID: jimmtest.ControllerUUID, Database: db.Database{ DB: jimmtest.PostgresDB(c, nil), }, @@ -1343,6 +1330,7 @@ var revokeCloudAccessTests = []struct { expectError: `failed to recognize given access: "some-unknown-access"`, }} +//nolint:gocognit func TestRevokeCloudAccess(t *testing.T) { c := qt.New(t) @@ -1360,6 +1348,7 @@ func TestRevokeCloudAccess(t *testing.T) { Err: tt.dialError, } j := &jimm.JIMM{ + UUID: jimmtest.ControllerUUID, Database: db.Database{ DB: jimmtest.PostgresDB(c, nil), }, @@ -1371,7 +1360,7 @@ func TestRevokeCloudAccess(t *testing.T) { c.Assert(err, qt.IsNil) env.PopulateDBAndPermissions(c, j.ResourceTag(), j.Database, client) - if tt.extraInitialTuples != nil && len(tt.extraInitialTuples) > 0 { + if len(tt.extraInitialTuples) > 0 { err = client.AddRelation(ctx, tt.extraInitialTuples...) c.Assert(err, qt.IsNil) } diff --git a/internal/jimm/cloudcredential.go b/internal/jimm/cloudcredential.go index 52587df66..5691a2c06 100644 --- a/internal/jimm/cloudcredential.go +++ b/internal/jimm/cloudcredential.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimm @@ -342,6 +342,9 @@ func (j *JIMM) GetCloudCredentialAttributes(ctx context.Context, user *openfga.U err = errors.E(op, err) return } + if len(attrs) == 0 { + return map[string]string{}, nil, nil + } if hidden { return @@ -377,8 +380,5 @@ func (j *JIMM) getCloudCredentialAttributes(ctx context.Context, cred *dbmodel.C if err != nil { return nil, errors.E(op, err) } - if len(attr) == 0 && cred.AuthType != "empty" { - return nil, errors.E(op, errors.CodeNotFound, "cloud-credential attributes not found") - } return attr, nil } diff --git a/internal/jimm/cloudcredential_test.go b/internal/jimm/cloudcredential_test.go index 293c761d2..4e4151e4d 100644 --- a/internal/jimm/cloudcredential_test.go +++ b/internal/jimm/cloudcredential_test.go @@ -1,10 +1,11 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test import ( "context" "database/sql" + "fmt" "sync" "testing" "time" @@ -1399,6 +1400,7 @@ func TestGetCloudCredential(t *testing.T) { } } +//nolint:gosec // Thinks credentials hardcoded. const forEachUserCloudCredentialEnv = `clouds: - name: cloud-1 regions: @@ -1521,6 +1523,7 @@ func TestForEachUserCloudCredential(t *testing.T) { } } +//nolint:gosec // Thinks credentials hardcoded. const getCloudCredentialAttributesEnv = `clouds: - name: test-cloud type: gce @@ -1536,6 +1539,10 @@ cloud-credentials: client-id: 1234 private-key: super-secret project-id: 5678 +- name: cred-2 + cloud: test-cloud + owner: bob@canonical.com + auth-type: certificate users: - username: alice@canonical.com controller-access: superuser @@ -1547,6 +1554,7 @@ var getCloudCredentialAttributesTests = []struct { username string hidden bool jimmAdmin bool + cred string expectAttributes map[string]string expectRedacted []string expectError string @@ -1555,16 +1563,25 @@ var getCloudCredentialAttributesTests = []struct { name: "OwnerNoHidden", username: "bob@canonical.com", jimmAdmin: true, + cred: "cred-1", expectAttributes: map[string]string{ "client-email": "bob@example.com", "client-id": "1234", "project-id": "5678", }, expectRedacted: []string{"private-key"}, +}, { + name: "OwnerNoAttributes", + username: "bob@canonical.com", + jimmAdmin: true, + cred: "cred-2", + expectAttributes: map[string]string{}, + expectRedacted: nil, }, { name: "OwnerWithHidden", username: "bob@canonical.com", hidden: true, + cred: "cred-1", expectAttributes: map[string]string{ "client-email": "bob@example.com", "client-id": "1234", @@ -1575,6 +1592,7 @@ var getCloudCredentialAttributesTests = []struct { name: "SuperUserNoHidden", username: "alice@canonical.com", jimmAdmin: true, + cred: "cred-1", expectAttributes: map[string]string{ "client-email": "bob@example.com", "client-id": "1234", @@ -1586,11 +1604,13 @@ var getCloudCredentialAttributesTests = []struct { username: "alice@canonical.com", hidden: true, jimmAdmin: true, + cred: "cred-1", expectError: `unauthorized`, expectErrorCode: errors.CodeUnauthorized, }, { name: "OtherUserUnauthorized", username: "charlie@canonical.com", + cred: "cred-1", expectError: `unauthorized`, expectErrorCode: errors.CodeUnauthorized, }} @@ -1621,7 +1641,8 @@ func TestGetCloudCredentialAttributes(t *testing.T) { env.PopulateDBAndPermissions(c, j.ResourceTag(), j.Database, client) u := env.User("bob@canonical.com").DBObject(c, j.Database) userBob := openfga.NewUser(&u, client) - cred, err := j.GetCloudCredential(ctx, userBob, names.NewCloudCredentialTag("test-cloud/bob@canonical.com/cred-1")) + credTag := fmt.Sprintf("test-cloud/bob@canonical.com/%s", test.cred) + cred, err := j.GetCloudCredential(ctx, userBob, names.NewCloudCredentialTag(credTag)) c.Assert(err, qt.IsNil) u = env.User(test.username).DBObject(c, j.Database) @@ -1712,7 +1733,7 @@ func TestCloudCredentialAttributeStore(t *testing.T) { // Update to an "empty" credential args.Credential.AuthType = "empty" - args.Credential.Attributes = nil + args.Credential.Attributes = map[string]string{} _, err = j.UpdateCloudCredential(ctx, user, args) c.Assert(err, qt.IsNil) diff --git a/internal/jimm/clouddefaults.go b/internal/jimm/clouddefaults.go index 467d7ff48..89a6ef568 100644 --- a/internal/jimm/clouddefaults.go +++ b/internal/jimm/clouddefaults.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package jimm import ( @@ -112,8 +113,5 @@ func (j *JIMM) ModelDefaultsForCloud(ctx context.Context, user *dbmodel.Identity result.Config[k] = d } } - if err != nil { - return jujuparams.ModelDefaultsResult{}, errors.E(op, err) - } return result, nil } diff --git a/internal/jimm/clouddefaults_test.go b/internal/jimm/clouddefaults_test.go index 5d1778d23..7371967ef 100644 --- a/internal/jimm/clouddefaults_test.go +++ b/internal/jimm/clouddefaults_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test @@ -133,7 +133,7 @@ func TestSetCloudDefaults(t *testing.T) { } c.Assert(j.Database.DB.Create(&cloud).Error, qt.IsNil) - j.Database.SetCloudDefaults(ctx, &dbmodel.CloudDefaults{ + err = j.Database.SetCloudDefaults(ctx, &dbmodel.CloudDefaults{ IdentityName: user.Name, Identity: *user, CloudID: cloud.ID, @@ -144,6 +144,7 @@ func TestSetCloudDefaults(t *testing.T) { "key2": "a test string", }, }) + c.Assert(err, qt.IsNil) defaults := map[string]interface{}{ "key1": float64(42), @@ -179,7 +180,6 @@ func TestSetCloudDefaults(t *testing.T) { cloud := dbmodel.Cloud{ Name: "test-cloud-1", - Type: "test-provider", Regions: []dbmodel.CloudRegion{{ Name: "test-region", }}, @@ -209,7 +209,6 @@ func TestSetCloudDefaults(t *testing.T) { cloud := dbmodel.Cloud{ Name: "test-cloud-1", - Type: "test-provider", Regions: []dbmodel.CloudRegion{{ Name: "test-region", }}, diff --git a/internal/jimm/controller.go b/internal/jimm/controller.go index f2af329e5..32c44c7ac 100644 --- a/internal/jimm/controller.go +++ b/internal/jimm/controller.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimm @@ -41,6 +41,174 @@ var ( } ) +// convertJujuCloudsToDbClouds converts all of the incoming Juju clouds (from a map) into +// a slice of dbmodel Clouds. +func convertJujuCloudsToDbClouds(clouds map[names.CloudTag]jujuparams.Cloud) []dbmodel.Cloud { + var dbClouds []dbmodel.Cloud + for tag, cld := range clouds { + var cloud dbmodel.Cloud + cloud.FromJujuCloud(cld) + cloud.Name = tag.Id() + dbClouds = append(dbClouds, cloud) + } + return dbClouds +} + +// getControllerModelSummary returns the controllers model summary. +func getControllerModelSummary(ctx context.Context, api API) (jujuparams.ModelSummary, error) { + var ms jujuparams.ModelSummary + if err := api.ControllerModelSummary(ctx, &ms); err != nil { + zapctx.Error(ctx, "failed to get model summary", zaputil.Error(err)) + return ms, err + } + return ms, nil +} + +// getCloudNameFromModelSummary returns the cloud name for a model summary. +func getCloudNameFromModelSummary(modelSummary jujuparams.ModelSummary) (string, error) { + cloudTag, err := names.ParseCloudTag(modelSummary.CloudTag) + if err != nil { + return "", err + } + return cloudTag.Id(), nil +} + +// addControllerTransactor adds a controller to the database ensuring it's clouds, regions +// and region priorities have also been persisted. Additionally, it ensures the region +// priorities are set too. +type addControllerTransactor struct { + jimm *JIMM + jujuClouds []dbmodel.Cloud + controller *dbmodel.Controller + tx *db.Database +} + +// newAddControllerTransactor creates a new addControllerTransactor. +func newAddControllerTransactor(j *JIMM, jujuClouds []dbmodel.Cloud, ctl *dbmodel.Controller, tx *db.Database) *addControllerTransactor { + return &addControllerTransactor{ + jimm: j, + jujuClouds: jujuClouds, + controller: ctl, + tx: tx, + } +} + +// addCloud adds a cloud from a juju API call within a transaction. +// +// After the cloud has been added, it is returned. +func (act *addControllerTransactor) addCloud(ctx context.Context, jujuCloud dbmodel.Cloud) (dbmodel.Cloud, error) { + cloud := jujuCloud + if err := act.tx.GetCloud(ctx, &cloud); err != nil { + if errors.ErrorCode(err) != errors.CodeNotFound { + zapctx.Error(ctx, "failed to fetch the cloud", zaputil.Error(err), zap.String("cloud-name", jujuCloud.Name)) + return cloud, err + } + err := act.tx.AddCloud(ctx, &cloud) + if err != nil && errors.ErrorCode(err) != errors.CodeAlreadyExists { + zapctx.Error(ctx, "failed to add cloud", zaputil.Error(err)) + return cloud, err + } + } + return cloud, nil +} + +// addCloudRegions iterates over the regions for the passed cloud, adding them to the database in the +// existing transaction. +// +// Additionally, it appends the added cloud region (to the database) to the passed +// cloud dbmodel.Cloud. This prevents the need to get the cloud from the database again. +func (act *addControllerTransactor) addCloudRegions(ctx context.Context, cloud dbmodel.Cloud, regions []dbmodel.CloudRegion) (dbmodel.Cloud, error) { + for _, reg := range regions { + if cloud.Region(reg.Name).ID != 0 { + continue + } + reg.CloudName = cloud.Name + if err := act.tx.AddCloudRegion(ctx, ®); err != nil { + zapctx.Error(ctx, "failed to add cloud region", zaputil.Error(err)) + return cloud, err + } + } + return cloud, nil +} + +// Sets controller cloud region priorities for this dbmodel.Controller, +// these priorities are set based on the following. +// +// Regions are defined on two fields, the cloud name and the region name. +// +// We have two priorities and they are set based on whether +// the incoming region matches the controllers model region. +// +// 1. Priority supported: +// If the region is NOT the same as the controllers region, +// it holds this priority. +// 2. Priority deployed: +// If the region is the same as the controller model, +// it holds this priority. +// +// It is expected that the cloud passed has already been loaded with the previously added +// regions. These regions will be appended to the controller's cloud region priorities. +// in preparation for adding the controller. +func (act *addControllerTransactor) setCloudRegionControllerPriorities(cloud dbmodel.Cloud, regions []dbmodel.CloudRegion) { + for _, cr := range regions { + reg := cloud.Region(cr.Name) + + priority := dbmodel.CloudRegionControllerPrioritySupported + + if cloud.Name == act.controller.CloudName && cr.Name == act.controller.CloudRegion { + priority = dbmodel.CloudRegionControllerPriorityDeployed + } + + act.controller.CloudRegions = append(act.controller.CloudRegions, dbmodel.CloudRegionControllerPriority{ + CloudRegion: reg, + Priority: uint(priority), + }) + } +} + +// Run runs the transactor to add a controller to JIMM. +func (act *addControllerTransactor) Run(ctx context.Context) error { + // Add clouds and their regions to db and sets the controllers + // cloud region priorities + for i := range act.jujuClouds { + incomingJujuCloud := act.jujuClouds[i] + + // Add the cloud + addedCloud, err := act.addCloud(ctx, incomingJujuCloud) + if err != nil { + return err + } + + // Add the clouds regions + _, err = act.addCloudRegions(ctx, addedCloud, incomingJujuCloud.Regions) + if err != nil { + return err + } + // Get the cloud again to populate it's regions (regions are preloaded) + // and now they can be used for updating the controller's region priorities. + if err := act.tx.GetCloud(ctx, &addedCloud); err != nil { + return err + } + + // Update controller dbmodel's region priotiries + act.setCloudRegionControllerPriorities(addedCloud, act.jujuClouds[i].Regions) + } + + // Finally, add the controller with all clouds and their regions set + if err := act.tx.AddController(ctx, act.controller); err != nil { + return err + } + return nil +} + +// addControllerTx stores the clouds, regions, cloud region priorities and the controller itself in the database determined +// from the incoming Juju API.Clouds() call. +func addControllerTx(ctx context.Context, j *JIMM, jujuClouds []dbmodel.Cloud, ctl *dbmodel.Controller) error { + return j.Database.Transaction(func(tx *db.Database) error { + return newAddControllerTransactor(j, jujuClouds, ctl, tx).Run(ctx) + }) +} + // AddController adds the specified controller to JIMM. Only // controller-admin level users may add new controllers. If the user adding // the controller is not authorized then an error with a code of @@ -52,28 +220,28 @@ var ( func (j *JIMM) AddController(ctx context.Context, user *openfga.User, ctl *dbmodel.Controller) error { const op = errors.Op("jimm.AddController") - if !user.JimmAdmin { - return errors.E(op, errors.CodeUnauthorized, "unauthorized") + if err := j.checkJimmAdmin(user); err != nil { + return err } - api, err := j.dial(ctx, ctl, names.ModelTag{}) + api, err := j.dialController(ctx, ctl) if err != nil { - zapctx.Error(ctx, "failed to dial the controller", zaputil.Error(err)) - return errors.E(op, err, "failed to dial the controller") + return errors.E(op, "failed to dial the controller", err) } defer api.Close() - var ms jujuparams.ModelSummary - if err := api.ControllerModelSummary(ctx, &ms); err != nil { - zapctx.Error(ctx, "failed to get model summary", zaputil.Error(err)) + modelSummary, err := getControllerModelSummary(ctx, api) + if err != nil { return errors.E(op, err, "failed to get model summary") } - ct, err := names.ParseCloudTag(ms.CloudTag) + + cloudName, err := getCloudNameFromModelSummary(modelSummary) if err != nil { return errors.E(op, err, "failed to parse the cloud tag") } - ctl.CloudName = ct.Id() - ctl.CloudRegion = ms.CloudRegion + + ctl.CloudName = cloudName + ctl.CloudRegion = modelSummary.CloudRegion // TODO(mhilton) add the controller model? clouds, err := api.Clouds(ctx) @@ -81,102 +249,36 @@ func (j *JIMM) AddController(ctx context.Context, user *openfga.User, ctl *dbmod return errors.E(op, err, "failed to fetch controller clouds") } - var dbClouds []dbmodel.Cloud - for tag, cld := range clouds { - var cloud dbmodel.Cloud - cloud.FromJujuCloud(cld) - cloud.Name = tag.Id() - dbClouds = append(dbClouds, cloud) - } + dbClouds := convertJujuCloudsToDbClouds(clouds) - credentialsStored := false + // TODO(ale8k): This shouldn't be necessary to check, but tests need updating + // to set insecure credential store explicitly. if j.CredentialStore != nil { err := j.CredentialStore.PutControllerCredentials(ctx, ctl.Name, ctl.AdminIdentityName, ctl.AdminPassword) if err != nil { return errors.E(op, err, "failed to store controller credentials") } - credentialsStored = true } - err = j.Database.Transaction(func(tx *db.Database) error { - for i := range dbClouds { - cloud := dbmodel.Cloud{ - Name: dbClouds[i].Name, - } - if err := tx.GetCloud(ctx, &cloud); err != nil { - if errors.ErrorCode(err) != errors.CodeNotFound { - zapctx.Error(ctx, "failed to fetch the cloud", zaputil.Error(err), zap.String("cloud-name", dbClouds[i].Name)) - return err - } - err := tx.AddCloud(ctx, &dbClouds[i]) - if err != nil && errors.ErrorCode(err) != errors.CodeAlreadyExists { - zapctx.Error(ctx, "failed to add cloud", zaputil.Error(err)) - return err - } - if err := tx.GetCloud(ctx, &cloud); err != nil { - zapctx.Error(ctx, "failed to fetch the cloud", zaputil.Error(err), zap.String("cloud-name", dbClouds[i].Name)) - return err - } - } - for _, reg := range dbClouds[i].Regions { - if cloud.Region(reg.Name).ID != 0 { - continue - } - reg.CloudName = cloud.Name - if err := tx.AddCloudRegion(ctx, ®); err != nil { - zapctx.Error(ctx, "failed to add cloud region", zaputil.Error(err)) - return err - } - cloud.Regions = append(cloud.Regions, reg) - } - for _, cr := range dbClouds[i].Regions { - reg := cloud.Region(cr.Name) - priority := dbmodel.CloudRegionControllerPrioritySupported - if cloud.Name == ctl.CloudName && cr.Name == ctl.CloudRegion { - priority = dbmodel.CloudRegionControllerPriorityDeployed - } - ctl.CloudRegions = append(ctl.CloudRegions, dbmodel.CloudRegionControllerPriority{ - CloudRegion: reg, - Priority: uint(priority), - }) - } - } - // if we already stored controller credentials in CredentialStore - // we should not store them plain text in JIMM's DB. - if credentialsStored { - ctl.AdminIdentityName = "" - ctl.AdminPassword = "" - } - if err := tx.AddController(ctx, ctl); err != nil { - if errors.ErrorCode(err) == errors.CodeAlreadyExists { - zapctx.Error(ctx, "failed to add controller", zaputil.Error(err)) - return errors.E(op, err, fmt.Sprintf("controller %q already exists", ctl.Name)) - } - zapctx.Error(ctx, "failed to add controller", zaputil.Error(err)) - return err + // Credential store will always be set either to vault or explicitly insecure, + // no need to be persist in db. + ctl.AdminIdentityName = "" + ctl.AdminPassword = "" + + if err := addControllerTx(ctx, j, dbClouds, ctl); err != nil { + zapctx.Error(ctx, "failed to add controller", zaputil.Error(err)) + if errors.ErrorCode(err) == errors.CodeAlreadyExists { + return errors.E(op, err, fmt.Sprintf("controller %q already exists", ctl.Name)) } - return nil - }) - if err != nil { return errors.E(op, err) } for _, cloud := range dbClouds { // If this cloud is the one used by the controller model then // it is available to all users. Other clouds require `juju grant-cloud` to add permissions. - if cloud.ResourceTag().String() == ms.CloudTag { - everyoneTag := names.NewUserTag(ofganames.EveryoneUser) - everyoneIdentity, err := dbmodel.NewIdentity(everyoneTag.Id()) - if err != nil { - zapctx.Error(ctx, "failed to create identity model", zap.Error(err)) - return errors.E(op, err) - } - everyone := openfga.NewUser( - everyoneIdentity, - j.OpenFGAClient, - ) - if err := everyone.SetCloudAccess(ctx, cloud.ResourceTag(), ofganames.CanAddModelRelation); err != nil { + if cloud.ResourceTag().String() == modelSummary.CloudTag { + if err := j.everyoneUser().SetCloudAccess(ctx, cloud.ResourceTag(), ofganames.CanAddModelRelation); err != nil { zapctx.Error(ctx, "failed to grant everyone add-model access", zap.Error(err)) } } @@ -228,6 +330,7 @@ func (j *JIMM) EarliestControllerVersion(ctx context.Context) (version.Number, e zap.String("version", controller.AgentVersion), zap.String("controller", controller.Name), ) + //nolint:nilerr // We wish to log without an error returned, TODO: Check with Ales return nil } if v == nil || versionNumber.Compare(*v) < 0 { @@ -244,42 +347,6 @@ func (j *JIMM) EarliestControllerVersion(ctx context.Context) (version.Number, e return *v, nil } -// controllerAccessLevel holds the controller access level for a user. -type controllerAccessLevel string - -const ( - // noAccess allows a user no permissions at all. - noAccess controllerAccessLevel = "" - - // loginAccess allows a user to log-ing into the subject. - loginAccess controllerAccessLevel = "login" - - // superuserAccess allows user unrestricted permissions in the subject. - superuserAccess controllerAccessLevel = "superuser" -) - -// validate returns error if the current is not a valid access level. -func (a controllerAccessLevel) validate() error { - switch a { - case noAccess, loginAccess, superuserAccess: - return nil - } - return errors.E(fmt.Sprintf("invalid access level %q", a)) -} - -func (a controllerAccessLevel) value() int { - switch a { - case noAccess: - return 0 - case loginAccess: - return 1 - case superuserAccess: - return 2 - default: - return -1 - } -} - // GetJimmControllerAccess returns the JIMM controller access level for the // requested user. func (j *JIMM) GetJimmControllerAccess(ctx context.Context, user *openfga.User, tag names.UserTag) (string, error) { @@ -325,25 +392,22 @@ func (j *JIMM) GetUserControllerAccess(ctx context.Context, user *openfga.User, return ToControllerAccessString(accessLevel), nil } -// ImportModel imports model with the specified uuid from the controller. +// ImportModel imports model with the specified UUID from the controller. func (j *JIMM) ImportModel(ctx context.Context, user *openfga.User, controllerName string, modelTag names.ModelTag, newOwner string) error { const op = errors.Op("jimm.ImportModel") - if !user.JimmAdmin { - return errors.E(op, errors.CodeUnauthorized, "unauthorized") + if err := j.checkJimmAdmin(user); err != nil { + return err } - controller := dbmodel.Controller{ - Name: controllerName, - } - err := j.Database.GetController(ctx, &controller) + controller, err := j.getControllerByName(ctx, controllerName) if err != nil { return errors.E(op, err) } - api, err := j.dial(ctx, &controller, names.ModelTag{}) + api, err := j.dialController(ctx, controller) if err != nil { - return errors.E(op, err) + return errors.E(op, "failed to dial the controller", err) } defer api.Close() @@ -361,7 +425,7 @@ func (j *JIMM) ImportModel(ctx context.Context, user *openfga.User, controllerNa return errors.E(op, err) } model.ControllerID = controller.ID - model.Controller = controller + model.Controller = *controller var ownerTag names.UserTag if newOwner != "" { @@ -407,7 +471,7 @@ func (j *JIMM) ImportModel(ctx context.Context, user *openfga.User, controllerNa // fetch cloud credential used by the model cloudTag, err := names.ParseCloudTag(modelInfo.CloudTag) if err != nil { - errors.E(op, err) + return errors.E(op, err) } // Note that the model already has a cloud credential configured which it will use when deploying new // applications. JIMM needs some cloud credential reference to be able to import the model so use any @@ -436,19 +500,14 @@ func (j *JIMM) ImportModel(ctx context.Context, user *openfga.User, controllerNa return errors.E(op, err) } - regionFound := false - for _, cr := range cloud.Regions { - if cr.Name == modelInfo.CloudRegion { - regionFound = true - model.CloudRegion = cr - model.CloudRegionID = cr.ID - break - } - } - if !regionFound { + cr := cloud.Region(modelInfo.CloudRegion) + if cr.Name != modelInfo.CloudRegion { return errors.E(op, "cloud region not found") } + model.CloudRegionID = cr.ID + model.CloudRegion = cr + err = j.Database.AddModel(ctx, &model) if err != nil { if errors.ErrorCode(err) == errors.CodeAlreadyExists { @@ -457,7 +516,13 @@ func (j *JIMM) ImportModel(ctx context.Context, user *openfga.User, controllerNa return errors.E(op, err) } - modelAPI, err := j.dial(ctx, &controller, modelTag) + return j.handleModelDeltas(ctx, controller, modelTag, model) +} + +func (j *JIMM) handleModelDeltas(ctx context.Context, controller *dbmodel.Controller, modelTag names.ModelTag, model dbmodel.Model) error { + const op = errors.Op("jimm.getModelDeltas") + + modelAPI, err := j.dialModel(ctx, controller, modelTag) if err != nil { return errors.E(op, err) } @@ -467,7 +532,11 @@ func (j *JIMM) ImportModel(ctx context.Context, user *openfga.User, controllerNa if err != nil { return errors.E(op, err) } - defer modelAPI.ModelWatcherStop(ctx, watcherID) + defer func() { + if err := modelAPI.ModelWatcherStop(ctx, watcherID); err != nil { + zapctx.Error(ctx, "failed to stop model watcher", zap.Error(err)) + } + }() deltas, err := modelAPI.ModelWatcherNext(ctx, watcherID) if err != nil { @@ -493,7 +562,6 @@ func (j *JIMM) ImportModel(ctx context.Context, user *openfga.User, controllerNa return errors.E(op, err) } } - return nil } diff --git a/internal/jimm/controller_test.go b/internal/jimm/controller_test.go index cd8f5f6b3..6d1398192 100644 --- a/internal/jimm/controller_test.go +++ b/internal/jimm/controller_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test @@ -63,17 +63,17 @@ func TestAddController(t *testing.T) { "B": 0xb, }, RegionConfig: map[string]map[string]interface{}{ - "eu-west-1": map[string]interface{}{ + "eu-west-1": { "B": 0xb0, "C": "C", }, - "eu-west-2": map[string]interface{}{ + "eu-west-2": { "B": 0xb1, "D": "D", }, }, }, - names.NewCloudTag("k8s"): jujuparams.Cloud{ + names.NewCloudTag("k8s"): { Type: "kubernetes", AuthTypes: []string{"userpass"}, Endpoint: "https://k8s.example.com", @@ -206,7 +206,7 @@ func TestAddControllerWithVault(t *testing.T) { api := &jimmtest.API{ Clouds_: func(context.Context) (map[names.CloudTag]jujuparams.Cloud, error) { clouds := map[names.CloudTag]jujuparams.Cloud{ - names.NewCloudTag("aws"): jujuparams.Cloud{ + names.NewCloudTag("aws"): { Type: "ec2", AuthTypes: []string{"userpass"}, Endpoint: "https://example.com", @@ -229,17 +229,17 @@ func TestAddControllerWithVault(t *testing.T) { "B": 0xb, }, RegionConfig: map[string]map[string]interface{}{ - "eu-west-1": map[string]interface{}{ + "eu-west-1": { "B": 0xb0, "C": "C", }, - "eu-west-2": map[string]interface{}{ + "eu-west-2": { "B": 0xb1, "D": "D", }, }, }, - names.NewCloudTag("k8s"): jujuparams.Cloud{ + names.NewCloudTag("k8s"): { Type: "kubernetes", AuthTypes: []string{"userpass"}, Endpoint: "https://k8s.example.com", @@ -1414,7 +1414,7 @@ func TestInitiateMigration(t *testing.T) { c := qt.New(t) mt1 := names.NewModelTag("00000002-0000-0000-0000-000000000003") - //mt2 := names.NewModelTag("00000002-0000-0000-0000-000000000004") + // mt2 := names.NewModelTag("00000002-0000-0000-0000-000000000004") migrationId1 := uuid.New().String() diff --git a/internal/jimm/credentials/credentials.go b/internal/jimm/credentials/credentials.go index 00cb9141c..b6ffdd0ed 100644 --- a/internal/jimm/credentials/credentials.go +++ b/internal/jimm/credentials/credentials.go @@ -1,4 +1,4 @@ -// Copyright 2023 canonical. +// Copyright 2024 Canonical. // Package credentials provides abstractions/definitions for credential storage // backends and caching mechanisms. diff --git a/internal/jimm/export_test.go b/internal/jimm/export_test.go index f9e47e656..77d5dbfeb 100644 --- a/internal/jimm/export_test.go +++ b/internal/jimm/export_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimm @@ -10,6 +10,7 @@ import ( "github.com/canonical/jimm/v3/internal/db" "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/openfga" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" ) @@ -52,3 +53,15 @@ func (j *JIMM) ListApplicationOfferUsers(ctx context.Context, offer names.Applic func (j *JIMM) ParseAndValidateTag(ctx context.Context, key string) (*ofganames.Tag, error) { return j.parseAndValidateTag(ctx, key) } + +func (j *JIMM) GetUser(ctx context.Context, identifier string) (*openfga.User, error) { + return j.getUser(ctx, identifier) +} + +func (j *JIMM) UpdateUserLastLogin(ctx context.Context, identifier string) error { + return j.updateUserLastLogin(ctx, identifier) +} + +func (j *JIMM) EveryoneUser() *openfga.User { + return j.everyoneUser() +} diff --git a/internal/jimm/identity.go b/internal/jimm/identity.go index 57af733d6..740f28353 100644 --- a/internal/jimm/identity.go +++ b/internal/jimm/identity.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimm diff --git a/internal/jimm/identity_test.go b/internal/jimm/identity_test.go index f7128c643..e189dc594 100644 --- a/internal/jimm/identity_test.go +++ b/internal/jimm/identity_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test diff --git a/internal/jimm/identitymodeldefaults.go b/internal/jimm/identitymodeldefaults.go index 833b760dc..8bb21f1ee 100644 --- a/internal/jimm/identitymodeldefaults.go +++ b/internal/jimm/identitymodeldefaults.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimm diff --git a/internal/jimm/identitymodeldefaults_test.go b/internal/jimm/identitymodeldefaults_test.go index e408d289a..a92d99cae 100644 --- a/internal/jimm/identitymodeldefaults_test.go +++ b/internal/jimm/identitymodeldefaults_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test @@ -67,7 +67,7 @@ func TestSetIdentityModelDefaults(t *testing.T) { c.Assert(j.Database.DB.Create(identity).Error, qt.IsNil) - j.Database.SetIdentityModelDefaults(ctx, &dbmodel.IdentityModelDefaults{ + err = j.Database.SetIdentityModelDefaults(ctx, &dbmodel.IdentityModelDefaults{ IdentityName: identity.Name, Identity: *identity, Defaults: map[string]interface{}{ @@ -75,6 +75,7 @@ func TestSetIdentityModelDefaults(t *testing.T) { "key2": "a test string", }, }) + c.Assert(err, qt.IsNil) defaults := map[string]interface{}{ "key1": float64(42), @@ -181,7 +182,7 @@ func TestIdentityModelDefaults(t *testing.T) { c.Assert(j.Database.DB.Create(identity).Error, qt.IsNil) - j.Database.SetIdentityModelDefaults(ctx, &dbmodel.IdentityModelDefaults{ + err = j.Database.SetIdentityModelDefaults(ctx, &dbmodel.IdentityModelDefaults{ IdentityName: identity.Name, Identity: *identity, Defaults: map[string]interface{}{ @@ -190,6 +191,7 @@ func TestIdentityModelDefaults(t *testing.T) { "key3": "a new value", }, }) + c.Assert(err, qt.IsNil) return testConfig{ identity: identity, diff --git a/internal/jimm/jimm.go b/internal/jimm/jimm.go index 92903833d..871036dd0 100644 --- a/internal/jimm/jimm.go +++ b/internal/jimm/jimm.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. // Package jimm contains the business logic used to manage clouds, // cloudcredentials and models. @@ -87,11 +87,6 @@ type JIMM struct { OAuthAuthenticator OAuthAuthenticator } -// OAuthAuthenticationService returns the JIMM's authentication service. -func (j *JIMM) OAuthAuthenticationService() OAuthAuthenticator { - return j.OAuthAuthenticator -} - // ResourceTag returns JIMM's controller tag stating its UUID. func (j *JIMM) ResourceTag() names.ControllerTag { return names.NewControllerTag(j.UUID) diff --git a/internal/jimm/jimm_test.go b/internal/jimm/jimm_test.go index 1539437ff..177b7d6df 100644 --- a/internal/jimm/jimm_test.go +++ b/internal/jimm/jimm_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test diff --git a/internal/jimm/model.go b/internal/jimm/model.go index c4034d5ff..0b60539b3 100644 --- a/internal/jimm/model.go +++ b/internal/jimm/model.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimm @@ -55,14 +55,13 @@ func (a *ModelCreateArgs) FromJujuModelCreateArgs(args *jujuparams.ModelCreateAr a.Name = args.Name a.Config = args.Config a.CloudRegion = args.CloudRegion - if args.CloudTag == "" { - return errors.E("no cloud specified for model; please specify one") - } - ct, err := names.ParseCloudTag(args.CloudTag) - if err != nil { - return errors.E(err, errors.CodeBadRequest) + if args.CloudTag != "" { + ct, err := names.ParseCloudTag(args.CloudTag) + if err != nil { + return errors.E(err, errors.CodeBadRequest) + } + a.Cloud = ct } - a.Cloud = ct if args.OwnerTag == "" { return errors.E("owner tag not specified") @@ -175,10 +174,17 @@ func (b *modelBuilder) WithConfig(cfg map[string]interface{}) *modelBuilder { } // WithCloud returns a builder with the specified cloud. -func (b *modelBuilder) WithCloud(cloud names.CloudTag) *modelBuilder { +func (b *modelBuilder) WithCloud(user *openfga.User, cloud names.CloudTag) *modelBuilder { if b.err != nil { return b } + + // if cloud was not specified then we try to determine if + // JIMM knows of only one cloud and use that one + if cloud.Id() == "" { + return b.withImplicitCloud(user) + } + c := dbmodel.Cloud{ Name: cloud.Id(), } @@ -192,6 +198,34 @@ func (b *modelBuilder) WithCloud(cloud names.CloudTag) *modelBuilder { return b } +// withImplicitCloud returns a builder with the only cloud known to JIMM. Should JIMM +// know of multiple clouds an error will be raised. +func (b *modelBuilder) withImplicitCloud(user *openfga.User) *modelBuilder { + if b.err != nil { + return b + } + var clouds []*dbmodel.Cloud + err := b.jimm.ForEachUserCloud(b.ctx, user, func(c *dbmodel.Cloud) error { + clouds = append(clouds, c) + return nil + }) + if err != nil { + b.err = err + return b + } + if len(clouds) == 0 { + b.err = fmt.Errorf("no available clouds") + return b + } + if len(clouds) != 1 { + b.err = fmt.Errorf("no cloud specified for model; please specify one") + return b + } + b.cloud = clouds[0] + + return b +} + // WithCloudRegion returns a builder with the specified cloud region. func (b *modelBuilder) WithCloudRegion(region string) *modelBuilder { if b.err != nil { @@ -563,12 +597,11 @@ func (j *JIMM) AddModel(ctx context.Context, user *openfga.User, args *ModelCrea builder = builder.WithConfig(cloudDefaults.Defaults) } - if args.Cloud != (names.CloudTag{}) { - builder = builder.WithCloud(args.Cloud) - if err := builder.Error(); err != nil { - return nil, errors.E(op, err) - } + builder = builder.WithCloud(user, args.Cloud) + if err := builder.Error(); err != nil { + return nil, errors.E(op, err) } + builder = builder.WithCloudRegion(args.CloudRegion) if err := builder.Error(); err != nil { return nil, errors.E(op, err) @@ -999,11 +1032,6 @@ func (j *JIMM) RevokeModelAccess(ctx context.Context, user *openfga.User, mt nam func (j *JIMM) DestroyModel(ctx context.Context, user *openfga.User, mt names.ModelTag, destroyStorage, force *bool, maxWait, timeout *time.Duration) error { const op = errors.Op("jimm.DestroyModel") - if destroyStorage != nil { - } - if force != nil { - } - err := j.doModelAdmin(ctx, user, mt, func(m *dbmodel.Model, api API) error { if err := api.DestroyModel(ctx, mt, destroyStorage, force, maxWait, timeout); err != nil { return err diff --git a/internal/jimm/model_status_parser.go b/internal/jimm/model_status_parser.go index e0f8062f9..dfc10255b 100644 --- a/internal/jimm/model_status_parser.go +++ b/internal/jimm/model_status_parser.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package jimm import ( @@ -8,7 +9,7 @@ import ( jujucmd "github.com/juju/cmd/v3" "github.com/juju/juju/cmd/juju/status" "github.com/juju/juju/cmd/juju/storage" - rpcparams "github.com/juju/juju/rpc/params" + jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" "github.com/juju/zaputil/zapctx" "go.uber.org/zap" @@ -166,7 +167,7 @@ func (f *formatterParamsRetriever) dialModel(ctx context.Context) error { // getModelStatus calls the FullStatus facade to return the full status for the current model // loaded in the formatterParamsRetriever. -func (f *formatterParamsRetriever) getModelStatus(ctx context.Context) (*rpcparams.FullStatus, error) { +func (f *formatterParamsRetriever) getModelStatus(ctx context.Context) (*jujuparams.FullStatus, error) { modelStatus, err := f.api.Status(ctx, nil) if err != nil { zapctx.Error(ctx, "failed to call FullStatus", zap.String("controller-uuid", f.model.Controller.UUID), zap.String("model-uuid", f.model.UUID.String), zap.Error(err)) @@ -204,17 +205,17 @@ func newStorageListAPI(ctx context.Context, api API) storageListAPI { } // ListStorageDetails implements storage.StorageListAPI. (From Juju) -func (s *storageListAPI) ListStorageDetails() ([]rpcparams.StorageDetails, error) { +func (s *storageListAPI) ListStorageDetails() ([]jujuparams.StorageDetails, error) { return s.api.ListStorageDetails(s.ctx) } // ListFilesystems implements storage.StorageListAPI. (From Juju) -func (s *storageListAPI) ListFilesystems(machines []string) ([]rpcparams.FilesystemDetailsListResult, error) { +func (s *storageListAPI) ListFilesystems(machines []string) ([]jujuparams.FilesystemDetailsListResult, error) { return s.api.ListFilesystems(s.ctx, machines) } // ListVolumes implements storage.StorageListAPI. (From Juju) -func (s *storageListAPI) ListVolumes(machines []string) ([]rpcparams.VolumeDetailsListResult, error) { +func (s *storageListAPI) ListVolumes(machines []string) ([]jujuparams.VolumeDetailsListResult, error) { return s.api.ListVolumes(s.ctx, machines) } diff --git a/internal/jimm/model_status_parser_test.go b/internal/jimm/model_status_parser_test.go index b05afbac1..24f884fa4 100644 --- a/internal/jimm/model_status_parser_test.go +++ b/internal/jimm/model_status_parser_test.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package jimm_test import ( diff --git a/internal/jimm/model_test.go b/internal/jimm/model_test.go index d1d6f58e4..f7e256803 100644 --- a/internal/jimm/model_test.go +++ b/internal/jimm/model_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test @@ -104,14 +104,6 @@ func TestModelCreateArgs(t *testing.T) { CloudCredentialTag: names.NewCloudCredentialTag("test-cloud/alice/test-credential-1").String(), }, expectedError: "owner tag not specified", - }, { - about: "cloud tag not specified", - args: jujuparams.ModelCreateArgs{ - Name: "test-model", - OwnerTag: names.NewUserTag("alice@canonical.com").String(), - CloudCredentialTag: names.NewCloudCredentialTag("test-cloud/alice/test-credential-1").String(), - }, - expectedError: "no cloud specified for model; please specify one", }} opts := []cmp.Option{ @@ -886,6 +878,198 @@ users: CloudCredentialTag: names.NewCloudCredentialTag("test-cloud/alice@canonical.com/test-credential-1").String(), }, expectError: "unauthorized", +}, { + name: "CreateModelWithImplicitCloud", + env: ` +clouds: +- name: test-cloud + type: test-provider + regions: + - name: test-region-1 + users: + - user: alice@canonical.com + access: add-model +user-defaults: +- user: alice@canonical.com + defaults: + key4: value4 +cloud-defaults: +- user: alice@canonical.com + cloud: test-cloud + region: test-region-1 + defaults: + key1: value1 + key2: value2 +- user: alice@canonical.com + cloud: test-cloud + defaults: + key3: value3 +cloud-credentials: +- name: test-credential-1 + owner: alice@canonical.com + cloud: test-cloud + auth-type: empty +controllers: +- name: controller-1 + uuid: 00000000-0000-0000-0000-0000-0000000000001 + cloud: test-cloud + region: test-region-1 + cloud-regions: + - cloud: test-cloud + region: test-region-1 + priority: 0 +- name: controller-2 + uuid: 00000000-0000-0000-0000-0000-0000000000002 + cloud: test-cloud + region: test-region-1 + cloud-regions: + - cloud: test-cloud + region: test-region-1 + priority: 2 +`[1:], + updateCredential: func(_ context.Context, _ jujuparams.TaggedCredential) ([]jujuparams.UpdateCredentialModelResult, error) { + return nil, nil + }, + grantJIMMModelAdmin: func(_ context.Context, _ names.ModelTag) error { + return nil + }, + createModel: assertConfig(map[string]interface{}{ + "key4": "value4", + }, createModel(` +uuid: 00000001-0000-0000-0000-0000-000000000001 +status: + status: started + info: running a test +life: alive +users: +- user: alice@canonical.com + access: admin +- user: bob + access: read +`[1:])), + username: "alice@canonical.com", + jimmAdmin: true, + args: jujuparams.ModelCreateArgs{ + Name: "test-model", + OwnerTag: names.NewUserTag("alice@canonical.com").String(), + CloudCredentialTag: names.NewCloudCredentialTag("test-cloud/alice@canonical.com/test-credential-1").String(), + }, + expectModel: dbmodel.Model{ + Name: "test-model", + UUID: sql.NullString{ + String: "00000001-0000-0000-0000-0000-000000000001", + Valid: true, + }, + Owner: dbmodel.Identity{ + Name: "alice@canonical.com", + }, + Controller: dbmodel.Controller{ + Name: "controller-2", + UUID: "00000000-0000-0000-0000-0000-0000000000002", + CloudName: "test-cloud", + CloudRegion: "test-region-1", + }, + CloudRegion: dbmodel.CloudRegion{ + Cloud: dbmodel.Cloud{ + Name: "test-cloud", + Type: "test-provider", + }, + Name: "test-region-1", + }, + CloudCredential: dbmodel.CloudCredential{ + Name: "test-credential-1", + AuthType: "empty", + }, + Life: state.Alive.String(), + Status: dbmodel.Status{ + Status: "started", + Info: "running a test", + }, + }, +}, { + name: "CreateModelWithImplicitCloudAndMultipleClouds", + env: ` +clouds: +- name: test-cloud + type: test-provider + regions: + - name: test-region-1 + users: + - user: alice@canonical.com + access: add-model +- name: test-cloud-2 + type: test-provider-2 + regions: + - name: test-region-2 + users: + - user: alice@canonical.com + access: add-model +user-defaults: +- user: alice@canonical.com + defaults: + key4: value4 +cloud-defaults: +- user: alice@canonical.com + cloud: test-cloud + region: test-region-1 + defaults: + key1: value1 + key2: value2 +- user: alice@canonical.com + cloud: test-cloud + defaults: + key3: value3 +cloud-credentials: +- name: test-credential-1 + owner: alice@canonical.com + cloud: test-cloud + auth-type: empty +controllers: +- name: controller-1 + uuid: 00000000-0000-0000-0000-0000-0000000000001 + cloud: test-cloud + region: test-region-1 + cloud-regions: + - cloud: test-cloud + region: test-region-1 + priority: 0 +- name: controller-2 + uuid: 00000000-0000-0000-0000-0000-0000000000002 + cloud: test-cloud + region: test-region-1 + cloud-regions: + - cloud: test-cloud + region: test-region-1 + priority: 2 +`[1:], + updateCredential: func(_ context.Context, _ jujuparams.TaggedCredential) ([]jujuparams.UpdateCredentialModelResult, error) { + return nil, nil + }, + grantJIMMModelAdmin: func(_ context.Context, _ names.ModelTag) error { + return nil + }, + createModel: assertConfig(map[string]interface{}{ + "key4": "value4", + }, createModel(` +uuid: 00000001-0000-0000-0000-0000-000000000001 +status: + status: started + info: running a test +life: alive +users: +- user: alice@canonical.com + access: admin +- user: bob + access: read +`[1:])), + username: "alice@canonical.com", + jimmAdmin: true, + args: jujuparams.ModelCreateArgs{ + Name: "test-model", + OwnerTag: names.NewUserTag("alice@canonical.com").String(), + CloudCredentialTag: names.NewCloudCredentialTag("test-cloud/alice@canonical.com/test-credential-1").String(), + }, + expectError: "no cloud specified for model; please specify one", }} func TestAddModel(t *testing.T) { @@ -982,6 +1166,9 @@ func createModel(template string) func(context.Context, *jujuparams.ModelCreateA func assertConfig(config map[string]interface{}, fnc func(context.Context, *jujuparams.ModelCreateArgs, *jujuparams.ModelInfo) error) func(context.Context, *jujuparams.ModelCreateArgs, *jujuparams.ModelInfo) error { return func(ctx context.Context, args *jujuparams.ModelCreateArgs, mi *jujuparams.ModelInfo) error { + if args.CloudTag == "" { + return errors.E("cloud not specified") + } if len(config) != len(args.Config) { return errors.E(fmt.Sprintf("expected %d config settings, got %d", len(config), len(args.Config))) } @@ -1991,6 +2178,7 @@ func TestGrantModelAccess(t *testing.T) { Err: tt.dialError, } j := &jimm.JIMM{ + UUID: jimmtest.ControllerUUID, Database: db.Database{ DB: jimmtest.PostgresDB(c, nil), }, @@ -2693,6 +2881,7 @@ var revokeModelAccessTests = []struct { expectError: `failed to recognize given access: "some-unknown-access"`, }} +//nolint:gocognit func TestRevokeModelAccess(t *testing.T) { c := qt.New(t) @@ -2710,6 +2899,7 @@ func TestRevokeModelAccess(t *testing.T) { Err: tt.dialError, } j := &jimm.JIMM{ + UUID: jimmtest.ControllerUUID, Database: db.Database{ DB: jimmtest.PostgresDB(c, nil), }, @@ -2720,7 +2910,7 @@ func TestRevokeModelAccess(t *testing.T) { c.Assert(err, qt.IsNil) env.PopulateDBAndPermissions(c, j.ResourceTag(), j.Database, client) - if tt.extraInitialTuples != nil && len(tt.extraInitialTuples) > 0 { + if len(tt.extraInitialTuples) > 0 { err = client.AddRelation(ctx, tt.extraInitialTuples...) c.Assert(err, qt.IsNil) } @@ -3283,6 +3473,7 @@ func TestValidateModelUpgrade(t *testing.T) { } } +//nolint:gosec // Thinks credentials hardcoded. const updateModelCredentialTestEnv = `clouds: - name: test-cloud type: test-provider diff --git a/internal/jimm/modelsummary.go b/internal/jimm/modelsummary.go index 548dd5362..4910521cc 100644 --- a/internal/jimm/modelsummary.go +++ b/internal/jimm/modelsummary.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimm diff --git a/internal/jimm/modelsummary_test.go b/internal/jimm/modelsummary_test.go index 9d3639afe..fdd559853 100644 --- a/internal/jimm/modelsummary_test.go +++ b/internal/jimm/modelsummary_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test diff --git a/internal/jimm/monitoring.go b/internal/jimm/monitoring.go index 669e80220..dc84f0dd3 100644 --- a/internal/jimm/monitoring.go +++ b/internal/jimm/monitoring.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package jimm import ( @@ -15,7 +16,7 @@ import ( // managed by JIMM as well as how many model each controller manages. func (j *JIMM) UpdateMetrics(ctx context.Context) { controllerCount := 0 - j.Database.ForEachController(ctx, func(c *dbmodel.Controller) error { + err := j.Database.ForEachController(ctx, func(c *dbmodel.Controller) error { controllerCount++ modelGauge, err := servermon.ModelCount.GetMetricWith(prometheus.Labels{"controller": c.Name}) if err != nil { @@ -30,5 +31,8 @@ func (j *JIMM) UpdateMetrics(ctx context.Context) { modelGauge.Set(float64(count)) return nil }) + if err != nil { + zapctx.Error(ctx, "update metrics failed", zap.Error(err)) + } servermon.ControllerCount.Set(float64(controllerCount)) } diff --git a/internal/jimm/purge_logs.go b/internal/jimm/purge_logs.go index 43291596a..c5a891ade 100644 --- a/internal/jimm/purge_logs.go +++ b/internal/jimm/purge_logs.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package jimm @@ -6,10 +6,11 @@ import ( "context" "time" - "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/openfga" "github.com/juju/zaputil/zapctx" "go.uber.org/zap" + + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/openfga" ) // PurgeLogs removes all audit logs before the given timestamp. Only JIMM diff --git a/internal/jimm/relation.go b/internal/jimm/relation.go index 8f317779c..20952e31c 100644 --- a/internal/jimm/relation.go +++ b/internal/jimm/relation.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package jimm diff --git a/internal/jimm/relation_test.go b/internal/jimm/relation_test.go index d6b372bf3..a506cdc4f 100644 --- a/internal/jimm/relation_test.go +++ b/internal/jimm/relation_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test @@ -239,7 +239,7 @@ func TestListObjectRelations(t *testing.T) { { description: "invalid user tag token", object: "foo" + user.Tag().String(), - expectedError: "failed to map tag foouser", + expectedError: "failed to map tag, unknown kind: foouser", }, } diff --git a/internal/jimm/runner.go b/internal/jimm/runner.go index dad9d66ff..524408c74 100644 --- a/internal/jimm/runner.go +++ b/internal/jimm/runner.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimm diff --git a/internal/jimm/runner_internal_test.go b/internal/jimm/runner_internal_test.go index c5520186b..ff925794c 100644 --- a/internal/jimm/runner_internal_test.go +++ b/internal/jimm/runner_internal_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimm diff --git a/internal/jimm/service_account.go b/internal/jimm/service_account.go index cb1550a91..c4f5bbdeb 100644 --- a/internal/jimm/service_account.go +++ b/internal/jimm/service_account.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package jimm @@ -6,14 +6,15 @@ import ( "context" "fmt" - "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/openfga" - ofganames "github.com/canonical/jimm/v3/internal/openfga/names" - jimmnames "github.com/canonical/jimm/v3/pkg/names" jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" "github.com/juju/zaputil/zapctx" "go.uber.org/zap" + + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/openfga" + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" + jimmnames "github.com/canonical/jimm/v3/pkg/names" ) // AddServiceAccount checks that no one owns the service account yet diff --git a/internal/jimm/service_account_test.go b/internal/jimm/service_account_test.go index a88962e92..12268a399 100644 --- a/internal/jimm/service_account_test.go +++ b/internal/jimm/service_account_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test diff --git a/internal/jimm/user.go b/internal/jimm/user.go index f406eb912..202be4278 100644 --- a/internal/jimm/user.go +++ b/internal/jimm/user.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimm @@ -12,9 +12,23 @@ import ( "github.com/canonical/jimm/v3/internal/openfga" ) -// GetUser fetches the user specified by the user's email or the service accounts ID +// UserLogin fetches a user based on their identityName and updates their last login time. +func (j *JIMM) UserLogin(ctx context.Context, identityName string) (*openfga.User, error) { + const op = errors.Op("jimm.UserLogin") + user, err := j.getUser(ctx, identityName) + if err != nil { + return nil, errors.E(op, err, errors.CodeUnauthorized) + } + err = j.updateUserLastLogin(ctx, identityName) + if err != nil { + return nil, errors.E(op, err, errors.CodeUnauthorized) + } + return user, nil +} + +// getUser fetches the user specified by the user's email or the service accounts ID // and returns an openfga User that can be used to verify user's permissions. -func (j *JIMM) GetUser(ctx context.Context, identifier string) (*openfga.User, error) { +func (j *JIMM) getUser(ctx context.Context, identifier string) (*openfga.User, error) { const op = errors.Op("jimm.GetUser") user, err := dbmodel.NewIdentity(identifier) @@ -36,7 +50,8 @@ func (j *JIMM) GetUser(ctx context.Context, identifier string) (*openfga.User, e return u, nil } -func (j *JIMM) UpdateUserLastLogin(ctx context.Context, identifier string) error { +// updateUserLastLogin updates the user's last login time in the database. +func (j *JIMM) updateUserLastLogin(ctx context.Context, identifier string) error { const op = errors.Op("jimm.UpdateUserLastLogin") user, err := dbmodel.NewIdentity(identifier) if err != nil { diff --git a/internal/jimm/user_test.go b/internal/jimm/user_test.go index e434632d6..956c3da5c 100644 --- a/internal/jimm/user_test.go +++ b/internal/jimm/user_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test @@ -24,7 +24,7 @@ func TestGetUser(t *testing.T) { client, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) c.Assert(err, qt.IsNil) db := &db.Database{ - DB: jimmtest.PostgresDB(c, func() time.Time { return time.Now() }), + DB: jimmtest.PostgresDB(c, time.Now), } j := &jimm.JIMM{ diff --git a/internal/jimm/utils.go b/internal/jimm/utils.go new file mode 100644 index 000000000..c97cdf225 --- /dev/null +++ b/internal/jimm/utils.go @@ -0,0 +1,80 @@ +// Copyright 2024 Canonical. +package jimm + +import ( + "context" + + "github.com/juju/names/v5" + "github.com/juju/zaputil" + "github.com/juju/zaputil/zapctx" + + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/openfga" + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" +) + +/** +* Authorisation utilities +**/ + +// everyoneUser is a convenience method to retrieve the "everyone" user +// whose permissions will translate into granting all users with access. +func (j *JIMM) everyoneUser() *openfga.User { + everyoneIdentity := &dbmodel.Identity{Name: ofganames.EveryoneUser} + return openfga.NewUser(everyoneIdentity, j.OpenFGAClient) +} + +// checkJimmAdmin checks if the user is a JIMM admin. +func (j *JIMM) checkJimmAdmin(user *openfga.User) error { + if !user.JimmAdmin { + return errors.E(errors.CodeUnauthorized, "unauthorized") + } + return nil +} + +// checkAdminAccess checks if the user is an admin of the controller. +func (j *JIMM) checkControllerAdminAccess(ctx context.Context, user *openfga.User, controller *dbmodel.Controller) error { + isAdministrator, err := openfga.IsAdministrator(ctx, user, controller.ResourceTag()) + if err != nil { + return err + } + if !isAdministrator { + return errors.E(errors.CodeUnauthorized, "unauthorized") + } + return nil +} + +/** +* General utility +**/ + +// getController gets the controller from the database by name. +func (j *JIMM) getControllerByName(ctx context.Context, controllerName string) (*dbmodel.Controller, error) { + controller := dbmodel.Controller{Name: controllerName} + err := j.Database.GetController(ctx, &controller) + if err != nil { + return nil, errors.E(errors.CodeNotFound, "controller not found") + } + return &controller, nil +} + +// dialController dials a controller. +func (j *JIMM) dialController(ctx context.Context, ctl *dbmodel.Controller) (API, error) { + api, err := j.dial(ctx, ctl, names.ModelTag{}) + if err != nil { + zapctx.Error(ctx, "failed to dial the controller", zaputil.Error(err)) + return nil, err + } + return api, nil +} + +// dialModel dials a model. +func (j *JIMM) dialModel(ctx context.Context, ctl *dbmodel.Controller, mt names.ModelTag) (API, error) { + api, err := j.dial(ctx, ctl, mt) + if err != nil { + zapctx.Error(ctx, "failed to dial the controller", zaputil.Error(err)) + return nil, err + } + return api, nil +} diff --git a/internal/jimm/watcher.go b/internal/jimm/watcher.go index 6efb27bff..393dc74f3 100644 --- a/internal/jimm/watcher.go +++ b/internal/jimm/watcher.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimm @@ -223,6 +223,8 @@ func (w *Watcher) deltaProcessedNotification() { // watchController connects to the given controller and watches for model // changes on the controller. +// +// nolint:gocognit // We ignore watch as watchers are removed in Juju 4.0. func (w *Watcher) watchController(ctx context.Context, ctl *dbmodel.Controller) error { const op = errors.Op("jimm.watchController") @@ -237,7 +239,11 @@ func (w *Watcher) watchController(ctx context.Context, ctl *dbmodel.Controller) if err != nil { return errors.E(op, err) } - defer api.AllModelWatcherStop(ctx, id) + defer func() { + if err := api.AllModelWatcherStop(ctx, id); err != nil { + zapctx.Error(ctx, "failed to stop all model watcher", zap.Error(err)) + } + }() checkDyingModel := func(m *dbmodel.Model) error { if m.Life == state.Dying.String() || m.Life == state.Dead.String() { @@ -284,16 +290,17 @@ func (w *Watcher) watchController(ctx context.Context, ctl *dbmodel.Controller) ControllerID: ctl.ID, } err := w.Database.GetModel(ctx, &m) - if err == nil { + switch { + case err == nil: st := modelState{ id: m.ID, machines: make(map[string]int64), units: make(map[string]bool), } modelStates[uuid] = &st - } else if errors.ErrorCode(err) == errors.CodeNotFound { + case errors.ErrorCode(err) == errors.CodeNotFound: modelStates[uuid] = nil - } else { + default: zapctx.Error(ctx, "cannot get model", zap.Error(err)) } return modelStates[uuid] @@ -374,7 +381,11 @@ func (w *Watcher) watchAllModelSummaries(ctx context.Context, ctl *dbmodel.Contr if err != nil { return errors.E(op, err) } - defer api.ModelSummaryWatcherStop(ctx, id) + defer func() { + if err := api.ModelSummaryWatcherStop(ctx, id); err != nil { + zapctx.Error(ctx, "failed to stop model summary watcher", zap.Error(err)) + } + }() // modelIDs contains the set of models running on the // controller that JIMM is interested in. @@ -461,6 +472,7 @@ func (w *Watcher) handleDelta(ctx context.Context, modelIDf func(string) *modelS var cores int64 machine := d.Entity.(*jujuparams.MachineInfo) if machine.HardwareCharacteristics != nil && machine.HardwareCharacteristics.CpuCores != nil { + //nolint:gosec // We expect cpu cores to fit into int64. cores = int64(*machine.HardwareCharacteristics.CpuCores) } sCores, ok := state.machines[eid.Id] diff --git a/internal/jimm/watcher_test.go b/internal/jimm/watcher_test.go index b39981b5f..9157313ab 100644 --- a/internal/jimm/watcher_test.go +++ b/internal/jimm/watcher_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimm_test @@ -510,6 +510,7 @@ var watcherTests = []struct { }, }} +//nolint:gocognit func TestWatcher(t *testing.T) { c := qt.New(t) diff --git a/internal/jimmhttp/auth_handler.go b/internal/jimmhttp/auth_handler.go index e8aa589e2..d8590122a 100644 --- a/internal/jimmhttp/auth_handler.go +++ b/internal/jimmhttp/auth_handler.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package jimmhttp import ( @@ -247,5 +248,8 @@ func writeError(ctx context.Context, w http.ResponseWriter, status int, err erro if err != nil { errMsg = " - " + err.Error() } - w.Write([]byte(http.StatusText(status) + errMsg)) + _, err = w.Write([]byte(http.StatusText(status) + errMsg)) + if err != nil { + zapctx.Error(ctx, "failed to write status text error", zap.Error(err)) + } } diff --git a/internal/jimmhttp/auth_handler_test.go b/internal/jimmhttp/auth_handler_test.go index c81942a9c..24f3f4b22 100644 --- a/internal/jimmhttp/auth_handler_test.go +++ b/internal/jimmhttp/auth_handler_test.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package jimmhttp_test import ( @@ -24,7 +25,7 @@ import ( func setupDbAndSessionStore(c *qt.C) (*db.Database, sessions.Store) { // Setup db ahead of time so we have access to session store db := &db.Database{ - DB: jimmtest.PostgresDB(c, func() time.Time { return time.Now() }), + DB: jimmtest.PostgresDB(c, time.Now), } c.Assert(db.Migrate(context.Background(), false), qt.IsNil) @@ -130,8 +131,10 @@ func TestCallbackFailsNoState(t *testing.T) { c.Assert(err, qt.IsNil) defer s.Close() - callbackURL := s.URL + jimmhttp.AuthResourceBasePath + jimmhttp.CallbackEndpoint - res, err := http.Get(callbackURL) + u, err := url.Parse(s.URL) + c.Assert(err, qt.IsNil) + u = u.JoinPath(jimmhttp.AuthResourceBasePath, jimmhttp.CallbackEndpoint) + res, err := http.Get(u.String()) c.Assert(err, qt.IsNil) defer res.Body.Close() diff --git a/internal/jimmhttp/handler.go b/internal/jimmhttp/handler.go index 079b48210..41e472bd5 100644 --- a/internal/jimmhttp/handler.go +++ b/internal/jimmhttp/handler.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package jimmhttp import ( diff --git a/internal/jimmhttp/http.go b/internal/jimmhttp/http.go index 8b2d3fdc4..49d807cb2 100644 --- a/internal/jimmhttp/http.go +++ b/internal/jimmhttp/http.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. // Package jimmhttp contains utilities for HTTP connections. package jimmhttp diff --git a/internal/jimmhttp/http_test.go b/internal/jimmhttp/http_test.go index a0a3c1f0e..56185c934 100644 --- a/internal/jimmhttp/http_test.go +++ b/internal/jimmhttp/http_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimmhttp_test @@ -62,6 +62,7 @@ func TestStripPathElement(t *testing.T) { hnd.ServeHTTP(rr, req) resp := rr.Result() + defer resp.Body.Close() c.Check(resp.StatusCode, qt.Equals, http.StatusOK) }) } diff --git a/internal/jimmhttp/websocket.go b/internal/jimmhttp/websocket.go index e14cb7b50..ce5731b03 100644 --- a/internal/jimmhttp/websocket.go +++ b/internal/jimmhttp/websocket.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimmhttp diff --git a/internal/jimmhttp/websocket_test.go b/internal/jimmhttp/websocket_test.go index c39a597bf..6a77a738e 100644 --- a/internal/jimmhttp/websocket_test.go +++ b/internal/jimmhttp/websocket_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimmhttp_test @@ -30,8 +30,9 @@ func TestWSHandler(t *testing.T) { c.Cleanup(srv.Close) var d websocket.Dialer - conn, _, err := d.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil) + conn, resp, err := d.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil) c.Assert(err, qt.IsNil) + defer resp.Body.Close() err = conn.WriteMessage(websocket.TextMessage, []byte("test!")) c.Assert(err, qt.IsNil) @@ -77,8 +78,9 @@ func TestWSHandlerPanic(t *testing.T) { c.Cleanup(srv.Close) var d websocket.Dialer - conn, _, err := d.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil) + conn, resp, err := d.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil) c.Assert(err, qt.IsNil) + defer resp.Body.Close() _, _, err = conn.ReadMessage() c.Assert(err, qt.ErrorMatches, `websocket: close 1011 \(internal server error\): test`) @@ -104,8 +106,9 @@ func TestWSHandlerNilServer(t *testing.T) { c.Cleanup(srv.Close) var d websocket.Dialer - conn, _, err := d.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil) + conn, resp, err := d.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil) c.Assert(err, qt.IsNil) + defer resp.Body.Close() _, _, err = conn.ReadMessage() c.Assert(err, qt.ErrorMatches, `websocket: close 1000 \(normal\)`) @@ -132,10 +135,11 @@ func TestWSHandlerAuthFailsServer(t *testing.T) { c.Cleanup(srv.Close) var d websocket.Dialer - conn, _, err := d.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), http.Header{ + conn, resp, err := d.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), http.Header{ "Cookie": []string{auth.SessionName + "=naughty_cookie"}, }) c.Assert(err, qt.IsNil) + defer resp.Body.Close() _, _, err = conn.ReadMessage() c.Assert(err, qt.ErrorMatches, `websocket: close 1011 \(internal server error\): authentication failed`) diff --git a/internal/jimmjwx/export_test.go b/internal/jimmjwx/export_test.go index baebf4705..2217aaa23 100644 --- a/internal/jimmjwx/export_test.go +++ b/internal/jimmjwx/export_test.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package jimmjwx var ( diff --git a/internal/jimmjwx/jimmjwx.go b/internal/jimmjwx/jimmjwx.go index afc6bf2cf..30e973e0f 100644 --- a/internal/jimmjwx/jimmjwx.go +++ b/internal/jimmjwx/jimmjwx.go @@ -1,4 +1,4 @@ -// Copyright 2023 canonical. +// Copyright 2024 Canonical. // Package jimmjwx provides utility functions for JOSE (Javascript Object Signing and Encryption) within // JIMM. It currently supports the following: diff --git a/internal/jimmjwx/jwks.go b/internal/jimmjwx/jwks.go index fa448e17e..0343b80be 100644 --- a/internal/jimmjwx/jwks.go +++ b/internal/jimmjwx/jwks.go @@ -1,4 +1,4 @@ -// Copyright 2023 canonical. +// Copyright 2024 Canonical. package jimmjwx @@ -66,7 +66,9 @@ func rotateJWKS(ctx context.Context, credStore credentials.CredentialStore, init zapctx.Debug(ctx, "setting initial expiry", zap.Time("time", initialExpiryTime)) err = putJwks(initialExpiryTime) if err != nil { - credStore.CleanupJWKS(ctx) + if jwksErr := credStore.CleanupJWKS(ctx); jwksErr != nil { + zapctx.Error(ctx, "failed to cleanup jwks", zap.Error(jwksErr)) + } return errors.E(err) } } else { @@ -77,7 +79,9 @@ func rotateJWKS(ctx context.Context, credStore credentials.CredentialStore, init // components exist from the previous failed expiry attempt. err = putJwks(time.Now().UTC().AddDate(0, 3, 0)) if err != nil { - credStore.CleanupJWKS(ctx) + if jwksErr := credStore.CleanupJWKS(ctx); jwksErr != nil { + zapctx.Error(ctx, "failed to cleanup jwks", zap.Error(jwksErr)) + } return errors.E(err) } zapctx.Debug(ctx, "set a new JWKS", zap.String("expiry", expires.String())) @@ -102,10 +106,6 @@ func (jwks *JWKSService) StartJWKSRotator(ctx context.Context, checkRotateRequir credStore := jwks.credentialStore - // For logging and monitoring purposes, we have the rotator spit errors into - // this buffered channel ((size * amount) * 2 of errors we are currently aware of and doubling it to prevent blocks) - errorChan := make(chan error, 8) - if err := rotateJWKS(ctx, credStore, initialRotateRequiredTime); err != nil { zapctx.Error(ctx, "Rotate JWKS error", zap.Error(err)) return errors.E(op, err) @@ -117,14 +117,12 @@ func (jwks *JWKSService) StartJWKSRotator(ctx context.Context, checkRotateRequir // // In this case we generate a new set, which should expire in 3 months. go func() { - defer close(errorChan) for { select { case <-checkRotateRequired: if err := rotateJWKS(ctx, credStore, initialRotateRequiredTime); err != nil { - errorChan <- err + zapctx.Error(ctx, "security failure", zap.Any("op", op), zap.NamedError("jwks-error", err)) } - case <-ctx.Done(): zapctx.Debug(ctx, "Shutdown for JWKS rotator complete.") return @@ -132,24 +130,6 @@ func (jwks *JWKSService) StartJWKSRotator(ctx context.Context, checkRotateRequir } }() - // If for any reason the rotator has an error, we simply receive the error - // in another routine dedicated to logging said errors. - go func(errChan <-chan error) { - for err := range errChan { - zapctx.Error( - ctx, - "security failure", - zap.Any("op", op), - zap.NamedError("jwks-error", err), - ) - select { - case <-ctx.Done(): - return - default: - } - } - }(errorChan) - return nil } diff --git a/internal/jimmjwx/jwks_test.go b/internal/jimmjwx/jwks_test.go index 76f91531f..cc9a48fd6 100644 --- a/internal/jimmjwx/jwks_test.go +++ b/internal/jimmjwx/jwks_test.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package jimmjwx_test import ( diff --git a/internal/jimmjwx/jwt.go b/internal/jimmjwx/jwt.go index 4184aeb05..f9f5b3045 100644 --- a/internal/jimmjwx/jwt.go +++ b/internal/jimmjwx/jwt.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package jimmjwx @@ -8,8 +8,6 @@ import ( "encoding/pem" "time" - "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/jimm/credentials" "github.com/google/uuid" "github.com/hashicorp/golang-lru/v2/expirable" "github.com/juju/zaputil/zapctx" @@ -17,6 +15,9 @@ import ( "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" "go.uber.org/zap" + + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/jimm/credentials" ) type JWTServiceParams struct { @@ -96,7 +97,7 @@ func NewJWTService(p JWTServiceParams) *JWTService { // and instead, a new JWT will be issued each time containing the required claims for // authz. func (j *JWTService) NewJWT(ctx context.Context, params JWTParams) ([]byte, error) { - jti, err := j.generateJTI(ctx) + jti, err := j.generateJTI() if err != nil { return nil, err } @@ -133,8 +134,13 @@ func (j *JWTService) NewJWT(ctx context.Context, params JWTParams) ([]byte, erro return nil, err } - signingKey.Set(jwk.AlgorithmKey, jwa.RS256) - signingKey.Set(jwk.KeyIDKey, pubKey.KeyID()) + if err := signingKey.Set(jwk.AlgorithmKey, jwa.RS256); err != nil { + return nil, err + } + + if err := signingKey.Set(jwk.KeyIDKey, pubKey.KeyID()); err != nil { + return nil, err + } token, err := jwt.NewBuilder(). Audience([]string{params.Controller}). @@ -164,7 +170,7 @@ func (j *JWTService) NewJWT(ctx context.Context, params JWTParams) ([]byte, erro // generateJTI uses a V4 UUID, giving a chance of 1 in 17Billion per year. // This should be good enough (hopefully) for a JWT ID. -func (j *JWTService) generateJTI(ctx context.Context) (string, error) { +func (j *JWTService) generateJTI() (string, error) { id, err := uuid.NewRandom() if err != nil { return "", err diff --git a/internal/jimmjwx/jwt_test.go b/internal/jimmjwx/jwt_test.go index 558289d2a..b17749d0c 100644 --- a/internal/jimmjwx/jwt_test.go +++ b/internal/jimmjwx/jwt_test.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package jimmjwx_test import ( @@ -7,11 +8,12 @@ import ( "testing" "time" - "github.com/canonical/jimm/v3/internal/jimmjwx" qt "github.com/frankban/quicktest" "github.com/lestrrat-go/iter/arrayiter" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" + + "github.com/canonical/jimm/v3/internal/jimmjwx" ) func TestRegisterJWKSCacheRegistersTheCacheSuccessfully(t *testing.T) { @@ -158,7 +160,8 @@ func TestCredentialCache(t *testing.T) { ctx := context.Background() set, _, err := jimmjwx.GenerateJWK(ctx) c.Assert(err, qt.IsNil) - store.PutJWKS(ctx, set) + err = store.PutJWKS(ctx, set) + c.Assert(err, qt.IsNil) vaultCache := jimmjwx.NewCredentialCache(store) gotSet, err := vaultCache.Get(ctx) c.Assert(err, qt.IsNil) diff --git a/internal/jimmjwx/utils_test.go b/internal/jimmjwx/utils_test.go index c00e0dd87..91f315523 100644 --- a/internal/jimmjwx/utils_test.go +++ b/internal/jimmjwx/utils_test.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package jimmjwx_test import ( @@ -65,12 +66,14 @@ func startAndTestRotator(c *qt.C, ctx context.Context, store credentials.Credent for i := 0; i < 60; i++ { if ks == nil { ks, err = store.GetJWKS(ctx) + if err != nil { + c.Logf("failed to get JWKS: %s", err) + } time.Sleep(500 * time.Millisecond) continue } - if ks != nil { - break - } + break + } c.Assert(err, qt.IsNil) key, ok := ks.Key(0) diff --git a/internal/jimmtest/api.go b/internal/jimmtest/api.go index f0759e04d..20ba4a5cb 100644 --- a/internal/jimmtest/api.go +++ b/internal/jimmtest/api.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimmtest diff --git a/internal/jimmtest/auth.go b/internal/jimmtest/auth.go index bd267b025..636670ee3 100644 --- a/internal/jimmtest/auth.go +++ b/internal/jimmtest/auth.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimmtest @@ -25,8 +25,10 @@ import ( "github.com/gorilla/sessions" "github.com/juju/juju/api" jujuparams "github.com/juju/juju/rpc/params" + "github.com/juju/zaputil/zapctx" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwt" + "go.uber.org/zap" "golang.org/x/oauth2" "github.com/canonical/jimm/v3/internal/auth" @@ -65,10 +67,9 @@ func (a Authenticator) Authenticate(_ context.Context, _ *jujuparams.LoginReques return a.User, a.Err } -type MockOAuthAuthenticator struct { +type mockOAuthAuthenticator struct { jimm.OAuthAuthenticator - AuthenticateBrowserSession_ func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) - c SimpleTester + c SimpleTester // PollingChan is used to simulate polling an OIDC server during the device flow. // It expects a username to be received that will be used to generate the user's access token. PollingChan <-chan string @@ -76,12 +77,15 @@ type MockOAuthAuthenticator struct { mockAccessToken string } -func NewMockOAuthAuthenticator(c SimpleTester, testChan <-chan string) MockOAuthAuthenticator { - return MockOAuthAuthenticator{c: c, PollingChan: testChan} +// NewMockOAuthAuthenticator creates a mock authenticator for tests. An channel can be passed in +// when testing the device flow to simulate polling an OIDC server. Provide a nil channel +// if the device flow will not be used in the test. +func NewMockOAuthAuthenticator(c SimpleTester, testChan <-chan string) mockOAuthAuthenticator { + return mockOAuthAuthenticator{c: c, PollingChan: testChan} } // Device is a mock implementation for the start of the device flow, returning dummy polling data. -func (m *MockOAuthAuthenticator) Device(ctx context.Context) (*oauth2.DeviceAuthResponse, error) { +func (m *mockOAuthAuthenticator) Device(ctx context.Context) (*oauth2.DeviceAuthResponse, error) { return &oauth2.DeviceAuthResponse{ DeviceCode: "test-device-code", UserCode: "test-user-code", @@ -94,7 +98,7 @@ func (m *MockOAuthAuthenticator) Device(ctx context.Context) (*oauth2.DeviceAuth // DeviceAccessToken is a mock implementation of the second step in the device flow where JIMM // polls an OIDC server for the device code. -func (m *MockOAuthAuthenticator) DeviceAccessToken(ctx context.Context, res *oauth2.DeviceAuthResponse) (*oauth2.Token, error) { +func (m *mockOAuthAuthenticator) DeviceAccessToken(ctx context.Context, res *oauth2.DeviceAuthResponse) (*oauth2.Token, error) { select { case username := <-m.PollingChan: m.polledUsername = username @@ -112,7 +116,7 @@ func (m *MockOAuthAuthenticator) DeviceAccessToken(ctx context.Context, res *oau // VerifySessionToken provides the mock implementation for verifying session tokens. // Allowing JIMM tests to create their own session tokens that will always be accepted. // Notice the use of jwt.ParseInsecure to skip JWT signature verification. -func (m *MockOAuthAuthenticator) VerifySessionToken(token string) (jwt.Token, error) { +func (m *mockOAuthAuthenticator) VerifySessionToken(token string) (jwt.Token, error) { errorFn := func(err error) error { return jimmerrors.E(err, jimmerrors.CodeUnauthorized) } @@ -135,7 +139,7 @@ func (m *MockOAuthAuthenticator) VerifySessionToken(token string) (jwt.Token, er // ExtractAndVerifyIDToken returns an ID token where the subject is equal to the username obtained during the device flow. // The auth token must match the one returned during the device flow. // If the polled username is empty it indicates an error that the device flow was not run prior to calling this function. -func (m *MockOAuthAuthenticator) ExtractAndVerifyIDToken(ctx context.Context, oauth2Token *oauth2.Token) (*oidc.IDToken, error) { +func (m *mockOAuthAuthenticator) ExtractAndVerifyIDToken(ctx context.Context, oauth2Token *oauth2.Token) (*oidc.IDToken, error) { if m.polledUsername == "" { return &oidc.IDToken{}, errors.New("unknown user for mock auth login") } @@ -146,28 +150,30 @@ func (m *MockOAuthAuthenticator) ExtractAndVerifyIDToken(ctx context.Context, oa } // Email returns the subject from an ID token. -func (m *MockOAuthAuthenticator) Email(idToken *oidc.IDToken) (string, error) { +func (m *mockOAuthAuthenticator) Email(idToken *oidc.IDToken) (string, error) { return idToken.Subject, nil } // UpdateIdentity is a no-op mock. -func (m *MockOAuthAuthenticator) UpdateIdentity(ctx context.Context, email string, token *oauth2.Token) error { +func (m *mockOAuthAuthenticator) UpdateIdentity(ctx context.Context, email string, token *oauth2.Token) error { return nil } // MintSessionToken creates an unsigned session token with the email provided. -func (m *MockOAuthAuthenticator) MintSessionToken(email string) (string, error) { +func (m *mockOAuthAuthenticator) MintSessionToken(email string) (string, error) { return newSessionToken(m.c, email, ""), nil } // AuthenticateBrowserSession unless overridden by the `AuthenticateBrowserSession_` field, it will return an authentication failure error. -func (m *MockOAuthAuthenticator) AuthenticateBrowserSession(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { - if m.AuthenticateBrowserSession_ != nil { - return m.AuthenticateBrowserSession_(ctx, w, req) - } +func (m *mockOAuthAuthenticator) AuthenticateBrowserSession(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { return ctx, errors.New("authentication failed") } +// VerifyClientCredentials always returns a nil error. +func (m *mockOAuthAuthenticator) VerifyClientCredentials(ctx context.Context, clientID string, clientSecret string) error { + return nil +} + // newSessionToken returns a serialised JWT that can be used in tests. // Tests using a mock authenticator can provide an empty signatureSecret // while integration tests must provide the same secret used when verifying JWTs. @@ -290,7 +296,10 @@ func runBrowserLogin(db *db.Database, sessionStore sessions.Store, username, pas http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { cookieString = r.Header.Get("Cookie") - w.Write([]byte(dashboardResponse)) + if _, err := w.Write([]byte(dashboardResponse)); err != nil { + zapctx.Error(context.Background(), "failed to write dashboard response", zap.Error(err)) + } + }, ), ) diff --git a/internal/jimmtest/cmp.go b/internal/jimmtest/cmp.go index 6bb017204..90db5e518 100644 --- a/internal/jimmtest/cmp.go +++ b/internal/jimmtest/cmp.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimmtest diff --git a/internal/jimmtest/env.go b/internal/jimmtest/env.go index d64d478d2..228058cc0 100644 --- a/internal/jimmtest/env.go +++ b/internal/jimmtest/env.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimmtest @@ -132,7 +132,7 @@ func (u User) addUserRelations(c *qt.C, jimmTag names.ControllerTag, db db.Datab } // addCloudRelations adds permissions the cloud should have and adds permissions for users to the cloud. -func (cl Cloud) addCloudRelations(c *qt.C, jimmTag names.ControllerTag, db db.Database, client *openfga.OFGAClient) { +func (cl Cloud) addCloudRelations(c *qt.C, db db.Database, client *openfga.OFGAClient) { for _, u := range cl.Users { dbUser := cl.env.User(u.User).DBObject(c, db) var relation openfga.Relation @@ -153,7 +153,7 @@ func (cl Cloud) addCloudRelations(c *qt.C, jimmTag names.ControllerTag, db db.Da } // addModelRelations adds permissions the model should have and adds permissions for users to the model. -func (m Model) addModelRelations(c *qt.C, jimmTag names.ControllerTag, db db.Database, client *openfga.OFGAClient) { +func (m Model) addModelRelations(c *qt.C, db db.Database, client *openfga.OFGAClient) { owner := openfga.NewUser(&m.dbo.Owner, client) err := owner.SetModelAccess(context.Background(), m.dbo.ResourceTag(), ofganames.AdministratorRelation) c.Assert(err, qt.IsNil) @@ -181,7 +181,7 @@ func (m Model) addModelRelations(c *qt.C, jimmTag names.ControllerTag, db db.Dat } // addControllerRelations adds permissions the model should have and adds permissions for users to the controller. -func (ctl Controller) addControllerRelations(c *qt.C, jimmTag names.ControllerTag, db db.Database, client *openfga.OFGAClient) { +func (ctl Controller) addControllerRelations(c *qt.C, client *openfga.OFGAClient) { if ctl.dbo.AdminIdentityName != "" { userIdentity, err := dbmodel.NewIdentity(ctl.dbo.AdminIdentityName) c.Assert(err, qt.IsNil) @@ -202,16 +202,17 @@ func (e *Environment) addJIMMRelations(c *qt.C, jimmTag names.ControllerTag, db user.addUserRelations(c, jimmTag, db, client) } for _, controller := range e.Controllers { - client.AddController(context.Background(), jimmTag, controller.dbo.ResourceTag()) + err := client.AddController(context.Background(), jimmTag, controller.dbo.ResourceTag()) + c.Assert(err, qt.IsNil) } for _, cl := range e.Clouds { - cl.addCloudRelations(c, jimmTag, db, client) + cl.addCloudRelations(c, db, client) } for _, m := range e.Models { - m.addModelRelations(c, jimmTag, db, client) + m.addModelRelations(c, db, client) } for _, ctl := range e.Controllers { - ctl.addControllerRelations(c, jimmTag, db, client) + ctl.addControllerRelations(c, client) } } @@ -474,6 +475,7 @@ func (m *Model) DBObject(c Tester, db db.Database) dbmodel.Model { m.env.Controller(m.Controller) migrationControllerID := sql.NullInt32{} if m.MigrationController != "" { + //nolint:gosec // Database IDs for tests will fit into int32. migrationControllerID.Int32 = int32(m.env.Controller(m.MigrationController).dbo.ID) migrationControllerID.Valid = true } diff --git a/internal/jimmtest/gorm.go b/internal/jimmtest/gorm.go index 6294fc363..9eced56c9 100644 --- a/internal/jimmtest/gorm.go +++ b/internal/jimmtest/gorm.go @@ -1,10 +1,11 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. // Package jimmtest contains useful helpers for testing JIMM. package jimmtest import ( "context" + //nolint:gosec // We're only using sha1 in tests. "crypto/sha1" "encoding/base64" "fmt" @@ -16,12 +17,13 @@ import ( "sync" "time" - "github.com/canonical/jimm/v3/internal/db" - "github.com/canonical/jimm/v3/internal/errors" "github.com/google/uuid" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" + + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/errors" ) // A Tester is the test interface required by this package. @@ -167,9 +169,10 @@ const maxDatabaseNameLength = 63 // sure no name collisions occur and also future calls with the same suggested // database name results in the same safe name. func computeSafeDatabaseName(suggestedName string) string { - re, _ := regexp.Compile(unsafeCharsPattern) + re := regexp.MustCompile(unsafeCharsPattern) safeName := re.ReplaceAllString(suggestedName, "_") + //nolint:gosec // We're only using sha1 in tests. hasher := sha1.New() // Provide some random chars for the hash. Useful where tests // have the same suite name and same test name. @@ -195,6 +198,7 @@ var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") func randSeq(n int) string { b := make([]rune, n) for i := range b { + //nolint:gosec // We're only using rand.Intn for tests. b[i] = letters[rand.Intn(len(letters))] } return string(b) diff --git a/internal/jimmtest/jimm.go b/internal/jimmtest/jimm.go index 2252c2c32..0b647b43a 100644 --- a/internal/jimmtest/jimm.go +++ b/internal/jimmtest/jimm.go @@ -1,10 +1,12 @@ +// Copyright 2024 Canonical. package jimmtest import ( "time" - jimmsvc "github.com/canonical/jimm/v3/cmd/jimmsrv/service" "github.com/coreos/go-oidc/v3/oidc" + + jimmsvc "github.com/canonical/jimm/v3/cmd/jimmsrv/service" ) // NewTestJimmParams returns a set of JIMM params with sensible defaults diff --git a/internal/jimmtest/jimm_mock.go b/internal/jimmtest/jimm_mock.go index fcfa07534..75f3daded 100644 --- a/internal/jimmtest/jimm_mock.go +++ b/internal/jimmtest/jimm_mock.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package jimmtest @@ -21,7 +21,6 @@ import ( "github.com/canonical/jimm/v3/internal/openfga" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" "github.com/canonical/jimm/v3/internal/pubsub" - "github.com/canonical/jimm/v3/pkg/api/params" jimmnames "github.com/canonical/jimm/v3/pkg/names" ) @@ -33,27 +32,21 @@ type JIMM struct { mocks.RelationService mocks.GroupService mocks.ControllerService + mocks.LoginService + mocks.ModelManager AddAuditLogEntry_ func(ale *dbmodel.AuditLogEntry) AddCloudToController_ func(ctx context.Context, user *openfga.User, controllerName string, tag names.CloudTag, cloud jujuparams.Cloud, force bool) error AddHostedCloud_ func(ctx context.Context, user *openfga.User, tag names.CloudTag, cloud jujuparams.Cloud, force bool) error - AddModel_ func(ctx context.Context, u *openfga.User, args *jimm.ModelCreateArgs) (*jujuparams.ModelInfo, error) AddServiceAccount_ func(ctx context.Context, u *openfga.User, clientId string) error Authenticate_ func(ctx context.Context, req *jujuparams.LoginRequest) (*openfga.User, error) - ChangeModelCredential_ func(ctx context.Context, user *openfga.User, modelTag names.ModelTag, cloudCredentialTag names.CloudCredentialTag) error CheckPermission_ func(ctx context.Context, user *openfga.User, cachedPerms map[string]string, desiredPerms map[string]interface{}) (map[string]string, error) CopyServiceAccountCredential_ func(ctx context.Context, u *openfga.User, svcAcc *openfga.User, cloudCredentialTag names.CloudCredentialTag) (names.CloudCredentialTag, []jujuparams.UpdateCredentialModelResult, error) - DestroyModel_ func(ctx context.Context, u *openfga.User, mt names.ModelTag, destroyStorage *bool, force *bool, maxWait *time.Duration, timeout *time.Duration) error DestroyOffer_ func(ctx context.Context, user *openfga.User, offerURL string, force bool) error - DumpModel_ func(ctx context.Context, u *openfga.User, mt names.ModelTag, simplified bool) (string, error) - DumpModelDB_ func(ctx context.Context, u *openfga.User, mt names.ModelTag) (map[string]interface{}, error) FindApplicationOffers_ func(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) FindAuditEvents_ func(ctx context.Context, user *openfga.User, filter db.AuditLogFilter) ([]dbmodel.AuditLogEntry, error) ForEachCloud_ func(ctx context.Context, user *openfga.User, f func(*dbmodel.Cloud) error) error - ForEachModel_ func(ctx context.Context, u *openfga.User, f func(*dbmodel.Model, jujuparams.UserAccessPermission) error) error ForEachUserCloud_ func(ctx context.Context, user *openfga.User, f func(*dbmodel.Cloud) error) error ForEachUserCloudCredential_ func(ctx context.Context, u *dbmodel.Identity, ct names.CloudTag, f func(cred *dbmodel.CloudCredential) error) error - ForEachUserModel_ func(ctx context.Context, u *openfga.User, f func(*dbmodel.Model, jujuparams.UserAccessPermission) error) error - FullModelStatus_ func(ctx context.Context, user *openfga.User, modelTag names.ModelTag, patterns []string) (*jujuparams.FullStatus, error) GetApplicationOffer_ func(ctx context.Context, user *openfga.User, offerURL string) (*jujuparams.ApplicationOfferAdminDetailsV5, error) GetApplicationOfferConsumeDetails_ func(ctx context.Context, user *openfga.User, details *jujuparams.ConsumeOfferDetails, v bakery.Version) error GetCloud_ func(ctx context.Context, u *openfga.User, tag names.CloudTag) (dbmodel.Cloud, error) @@ -61,7 +54,6 @@ type JIMM struct { GetCloudCredentialAttributes_ func(ctx context.Context, u *openfga.User, cred *dbmodel.CloudCredential, hidden bool) (attrs map[string]string, redacted []string, err error) GetCredentialStore_ func() jimmcreds.CredentialStore GetJimmControllerAccess_ func(ctx context.Context, user *openfga.User, tag names.UserTag) (string, error) - GetUser_ func(ctx context.Context, username string) (*openfga.User, error) FetchIdentity_ func(ctx context.Context, username string) (*openfga.User, error) CountIdentities_ func(ctx context.Context, user *openfga.User) (int, error) ListIdentities_ func(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]openfga.User, error) @@ -73,19 +65,12 @@ type JIMM struct { GrantModelAccess_ func(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error GrantOfferAccess_ func(ctx context.Context, u *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) error GrantServiceAccountAccess_ func(ctx context.Context, u *openfga.User, svcAccTag jimmnames.ServiceAccountTag, entities []string) error - ImportModel_ func(ctx context.Context, user *openfga.User, controllerName string, modelTag names.ModelTag, newOwner string) error - IdentityModelDefaults_ func(ctx context.Context, user *dbmodel.Identity) (map[string]interface{}, error) InitiateMigration_ func(ctx context.Context, user *openfga.User, spec jujuparams.MigrationSpec) (jujuparams.InitiateMigrationResult, error) InitiateInternalMigration_ func(ctx context.Context, user *openfga.User, modelTag names.ModelTag, targetController string) (jujuparams.InitiateMigrationResult, error) ListApplicationOffers_ func(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) - ModelDefaultsForCloud_ func(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag) (jujuparams.ModelDefaultsResult, error) - ModelInfo_ func(ctx context.Context, u *openfga.User, mt names.ModelTag) (*jujuparams.ModelInfo, error) - ModelStatus_ func(ctx context.Context, u *openfga.User, mt names.ModelTag) (*jujuparams.ModelStatus, error) Offer_ func(ctx context.Context, user *openfga.User, offer jimm.AddApplicationOfferParams) error - OAuthAuthenticationService_ func() jimm.OAuthAuthenticator PubSubHub_ func() *pubsub.Hub PurgeLogs_ func(ctx context.Context, user *openfga.User, before time.Time) (int64, error) - QueryModelsJq_ func(ctx context.Context, modelUUIDs []string, jqQuery string) (params.CrossModelQueryResponse, error) RemoveCloud_ func(ctx context.Context, u *openfga.User, ct names.CloudTag) error RemoveCloudFromController_ func(ctx context.Context, u *openfga.User, controllerName string, ct names.CloudTag) error ResourceTag_ func() names.ControllerTag @@ -94,17 +79,12 @@ type JIMM struct { RevokeCloudCredential_ func(ctx context.Context, user *dbmodel.Identity, tag names.CloudCredentialTag, force bool) error RevokeModelAccess_ func(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error RevokeOfferAccess_ func(ctx context.Context, user *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) (err error) - SetModelDefaults_ func(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag, region string, configs map[string]interface{}) error SetIdentityModelDefaults_ func(ctx context.Context, user *dbmodel.Identity, configs map[string]interface{}) error ToJAASTag_ func(ctx context.Context, tag *ofganames.Tag, resolveUUIDs bool) (string, error) - UnsetModelDefaults_ func(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag, region string, keys []string) error UpdateApplicationOffer_ func(ctx context.Context, controller *dbmodel.Controller, offerUUID string, removed bool) error UpdateCloud_ func(ctx context.Context, u *openfga.User, ct names.CloudTag, cloud jujuparams.Cloud) error UpdateCloudCredential_ func(ctx context.Context, u *openfga.User, args jimm.UpdateCloudCredentialArgs) ([]jujuparams.UpdateCredentialModelResult, error) - UpdateMigratedModel_ func(ctx context.Context, user *openfga.User, modelTag names.ModelTag, targetControllerName string) error - UpdateUserLastLogin_ func(ctx context.Context, identifier string) error - ValidateModelUpgrade_ func(ctx context.Context, u *openfga.User, mt names.ModelTag, force bool) error - WatchAllModelSummaries_ func(ctx context.Context, controller *dbmodel.Controller) (_ func() error, err error) + UserLogin_ func(ctx context.Context, identityName string) (*openfga.User, error) } func (j *JIMM) AddAuditLogEntry(ale *dbmodel.AuditLogEntry) { @@ -125,12 +105,6 @@ func (j *JIMM) AddHostedCloud(ctx context.Context, user *openfga.User, tag names } return j.AddHostedCloud_(ctx, user, tag, cloud, force) } -func (j *JIMM) AddModel(ctx context.Context, u *openfga.User, args *jimm.ModelCreateArgs) (_ *jujuparams.ModelInfo, err error) { - if j.AddModel_ == nil { - return nil, errors.E(errors.CodeNotImplemented) - } - return j.AddModel_(ctx, u, args) -} func (j *JIMM) AddServiceAccount(ctx context.Context, u *openfga.User, clientId string) error { if j.AddServiceAccount_ == nil { @@ -152,42 +126,19 @@ func (j *JIMM) Authenticate(ctx context.Context, req *jujuparams.LoginRequest) ( } return j.Authenticate_(ctx, req) } -func (j *JIMM) ChangeModelCredential(ctx context.Context, user *openfga.User, modelTag names.ModelTag, cloudCredentialTag names.CloudCredentialTag) error { - if j.ChangeModelCredential_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.ChangeModelCredential_(ctx, user, modelTag, cloudCredentialTag) -} + func (j *JIMM) CheckPermission(ctx context.Context, user *openfga.User, cachedPerms map[string]string, desiredPerms map[string]interface{}) (map[string]string, error) { if j.CheckPermission_ == nil { return nil, errors.E(errors.CodeNotImplemented) } return j.CheckPermission_(ctx, user, cachedPerms, desiredPerms) } -func (j *JIMM) DestroyModel(ctx context.Context, u *openfga.User, mt names.ModelTag, destroyStorage *bool, force *bool, maxWait *time.Duration, timeout *time.Duration) error { - if j.DestroyModel_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.DestroyModel_(ctx, u, mt, destroyStorage, force, maxWait, timeout) -} func (j *JIMM) DestroyOffer(ctx context.Context, user *openfga.User, offerURL string, force bool) error { if j.DestroyOffer_ == nil { return errors.E(errors.CodeNotImplemented) } return j.DestroyOffer_(ctx, user, offerURL, force) } -func (j *JIMM) DumpModel(ctx context.Context, u *openfga.User, mt names.ModelTag, simplified bool) (string, error) { - if j.DumpModel_ == nil { - return "", errors.E(errors.CodeNotImplemented) - } - return j.DumpModel_(ctx, u, mt, simplified) -} -func (j *JIMM) DumpModelDB(ctx context.Context, u *openfga.User, mt names.ModelTag) (map[string]interface{}, error) { - if j.DumpModelDB_ == nil { - return nil, errors.E(errors.CodeNotImplemented) - } - return j.DumpModelDB_(ctx, u, mt) -} func (j *JIMM) FindApplicationOffers(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) { if j.FindApplicationOffers_ == nil { return nil, errors.E(errors.CodeNotImplemented) @@ -206,12 +157,7 @@ func (j *JIMM) ForEachCloud(ctx context.Context, user *openfga.User, f func(*dbm } return j.ForEachCloud_(ctx, user, f) } -func (j *JIMM) ForEachModel(ctx context.Context, u *openfga.User, f func(*dbmodel.Model, jujuparams.UserAccessPermission) error) error { - if j.ForEachModel_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.ForEachModel_(ctx, u, f) -} + func (j *JIMM) ForEachUserCloud(ctx context.Context, user *openfga.User, f func(*dbmodel.Cloud) error) error { if j.ForEachUserCloud_ == nil { return errors.E(errors.CodeNotImplemented) @@ -224,18 +170,7 @@ func (j *JIMM) ForEachUserCloudCredential(ctx context.Context, u *dbmodel.Identi } return j.ForEachUserCloudCredential_(ctx, u, ct, f) } -func (j *JIMM) ForEachUserModel(ctx context.Context, u *openfga.User, f func(*dbmodel.Model, jujuparams.UserAccessPermission) error) error { - if j.ForEachUserModel_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.ForEachUserModel_(ctx, u, f) -} -func (j *JIMM) FullModelStatus(ctx context.Context, user *openfga.User, modelTag names.ModelTag, patterns []string) (*jujuparams.FullStatus, error) { - if j.FullModelStatus_ == nil { - return nil, errors.E(errors.CodeNotImplemented) - } - return j.FullModelStatus_(ctx, user, modelTag, patterns) -} + func (j *JIMM) GetApplicationOffer(ctx context.Context, user *openfga.User, offerURL string) (*jujuparams.ApplicationOfferAdminDetailsV5, error) { if j.GetApplicationOffer_ == nil { return nil, errors.E(errors.CodeNotImplemented) @@ -279,12 +214,6 @@ func (j *JIMM) GetJimmControllerAccess(ctx context.Context, user *openfga.User, } return j.GetJimmControllerAccess_(ctx, user, tag) } -func (j *JIMM) GetUser(ctx context.Context, username string) (*openfga.User, error) { - if j.GetUser_ == nil { - return nil, errors.E(errors.CodeNotImplemented) - } - return j.GetUser_(ctx, username) -} func (j *JIMM) FetchIdentity(ctx context.Context, username string) (*openfga.User, error) { if j.FetchIdentity_ == nil { return nil, errors.E(errors.CodeNotImplemented) @@ -353,12 +282,6 @@ func (j *JIMM) GrantServiceAccountAccess(ctx context.Context, u *openfga.User, s return j.GrantServiceAccountAccess_(ctx, u, svcAccTag, entities) } -func (j *JIMM) ImportModel(ctx context.Context, user *openfga.User, controllerName string, modelTag names.ModelTag, newOwner string) error { - if j.ImportModel_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.ImportModel_(ctx, user, controllerName, modelTag, newOwner) -} func (j *JIMM) InitiateMigration(ctx context.Context, user *openfga.User, spec jujuparams.MigrationSpec) (jujuparams.InitiateMigrationResult, error) { if j.InitiateMigration_ == nil { return jujuparams.InitiateMigrationResult{}, errors.E(errors.CodeNotImplemented) @@ -377,36 +300,12 @@ func (j *JIMM) ListApplicationOffers(ctx context.Context, user *openfga.User, fi } return j.ListApplicationOffers_(ctx, user, filters...) } -func (j *JIMM) ModelDefaultsForCloud(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag) (jujuparams.ModelDefaultsResult, error) { - if j.ModelDefaultsForCloud_ == nil { - return jujuparams.ModelDefaultsResult{}, errors.E(errors.CodeNotImplemented) - } - return j.ModelDefaultsForCloud_(ctx, user, cloudTag) -} -func (j *JIMM) ModelInfo(ctx context.Context, u *openfga.User, mt names.ModelTag) (*jujuparams.ModelInfo, error) { - if j.ModelInfo_ == nil { - return nil, errors.E(errors.CodeNotImplemented) - } - return j.ModelInfo_(ctx, u, mt) -} -func (j *JIMM) ModelStatus(ctx context.Context, u *openfga.User, mt names.ModelTag) (*jujuparams.ModelStatus, error) { - if j.ModelStatus_ == nil { - return nil, errors.E(errors.CodeNotImplemented) - } - return j.ModelStatus_(ctx, u, mt) -} func (j *JIMM) Offer(ctx context.Context, user *openfga.User, offer jimm.AddApplicationOfferParams) error { if j.Offer_ == nil { return errors.E(errors.CodeNotImplemented) } return j.Offer_(ctx, user, offer) } -func (j *JIMM) OAuthAuthenticationService() jimm.OAuthAuthenticator { - if j.OAuthAuthenticationService_ == nil { - panic("not implemented") - } - return j.OAuthAuthenticationService_() -} func (j *JIMM) PubSubHub() *pubsub.Hub { if j.PubSubHub_ == nil { panic("not implemented") @@ -419,12 +318,6 @@ func (j *JIMM) PurgeLogs(ctx context.Context, user *openfga.User, before time.Ti } return j.PurgeLogs_(ctx, user, before) } -func (j *JIMM) QueryModelsJq(ctx context.Context, models []string, jqQuery string) (params.CrossModelQueryResponse, error) { - if j.QueryModelsJq_ == nil { - return params.CrossModelQueryResponse{}, errors.E(errors.CodeNotImplemented) - } - return j.QueryModelsJq_(ctx, models, jqQuery) -} func (j *JIMM) RemoveCloud(ctx context.Context, u *openfga.User, ct names.CloudTag) error { if j.RemoveCloud_ == nil { return errors.E(errors.CodeNotImplemented) @@ -473,13 +366,6 @@ func (j *JIMM) RevokeOfferAccess(ctx context.Context, user *openfga.User, offerU } return j.RevokeOfferAccess_(ctx, user, offerURL, ut, access) } - -func (j *JIMM) SetModelDefaults(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag, region string, configs map[string]interface{}) error { - if j.SetModelDefaults_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.SetModelDefaults_(ctx, user, cloudTag, region, configs) -} func (j *JIMM) SetIdentityModelDefaults(ctx context.Context, user *dbmodel.Identity, configs map[string]interface{}) error { if j.SetIdentityModelDefaults_ == nil { return errors.E(errors.CodeNotImplemented) @@ -492,12 +378,7 @@ func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs b } return j.ToJAASTag_(ctx, tag, resolveUUIDs) } -func (j *JIMM) UnsetModelDefaults(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag, region string, keys []string) error { - if j.UnsetModelDefaults_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.UnsetModelDefaults_(ctx, user, cloudTag, region, keys) -} + func (j *JIMM) UpdateApplicationOffer(ctx context.Context, controller *dbmodel.Controller, offerUUID string, removed bool) error { if j.UpdateApplicationOffer_ == nil { return errors.E(errors.CodeNotImplemented) @@ -516,33 +397,9 @@ func (j *JIMM) UpdateCloudCredential(ctx context.Context, u *openfga.User, args } return j.UpdateCloudCredential_(ctx, u, args) } -func (j *JIMM) UpdateMigratedModel(ctx context.Context, user *openfga.User, modelTag names.ModelTag, targetControllerName string) error { - if j.UpdateMigratedModel_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.UpdateMigratedModel_(ctx, user, modelTag, targetControllerName) -} -func (j *JIMM) UpdateUserLastLogin(ctx context.Context, identifier string) error { - if j.UpdateUserLastLogin_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.UpdateUserLastLogin_(ctx, identifier) -} -func (j *JIMM) IdentityModelDefaults(ctx context.Context, user *dbmodel.Identity) (map[string]interface{}, error) { - if j.IdentityModelDefaults_ == nil { - return nil, errors.E(errors.CodeNotImplemented) - } - return j.IdentityModelDefaults_(ctx, user) -} -func (j *JIMM) ValidateModelUpgrade(ctx context.Context, u *openfga.User, mt names.ModelTag, force bool) error { - if j.ValidateModelUpgrade_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.ValidateModelUpgrade_(ctx, u, mt, force) -} -func (j *JIMM) WatchAllModelSummaries(ctx context.Context, controller *dbmodel.Controller) (_ func() error, err error) { - if j.WatchAllModelSummaries_ == nil { +func (j *JIMM) UserLogin(ctx context.Context, identityName string) (*openfga.User, error) { + if j.UserLogin_ == nil { return nil, errors.E(errors.CodeNotImplemented) } - return j.WatchAllModelSummaries_(ctx, controller) + return j.UserLogin_(ctx, identityName) } diff --git a/internal/jimmtest/keycloak.go b/internal/jimmtest/keycloak.go index 19404c143..8a85af2d3 100644 --- a/internal/jimmtest/keycloak.go +++ b/internal/jimmtest/keycloak.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package jimmtest @@ -12,10 +12,11 @@ import ( "net/url" "strings" - "github.com/canonical/jimm/v3/internal/errors" "github.com/google/uuid" "github.com/juju/zaputil/zapctx" "go.uber.org/zap" + + "github.com/canonical/jimm/v3/internal/errors" ) // These constants are based on the `docker-compose.yaml` and `local/keycloak/jimm-realm.json` content. @@ -34,7 +35,8 @@ const ( keycloakAdminUsername = "jimm" keycloakAdminPassword = "jimm" keycloakAdminCLIUsername = "admin-cli" - keycloakAdminCLISecret = "DOLcuE5Cd7IxuR7JE4hpAUxaLF7RlAWh" + //nolint:gosec // Thinks credentials exposed. Only used for test. + keycloakAdminCLISecret = "DOLcuE5Cd7IxuR7JE4hpAUxaLF7RlAWh" ) // KeycloakUser represents a basic user created in Keycloak. diff --git a/internal/jimmtest/logging.go b/internal/jimmtest/logging.go index aa0a30011..e4875924b 100644 --- a/internal/jimmtest/logging.go +++ b/internal/jimmtest/logging.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package jimmtest import ( @@ -51,13 +52,15 @@ func (s *LoggingSuite) setUp(c *gc.C) { // Don't use the default writer for the test logging, which // means we can still get logging output from tests that // replace the default writer. - loggo.RegisterWriter(loggo.DefaultWriterName, discardWriter{}) - loggo.RegisterWriter("loggingsuite", zaputil.NewLoggoWriter(logger)) + err := loggo.RegisterWriter(loggo.DefaultWriterName, discardWriter{}) + c.Assert(err, gc.IsNil) + err = loggo.RegisterWriter("loggingsuite", zaputil.NewLoggoWriter(logger)) + c.Assert(err, gc.IsNil) level := "DEBUG" if envLevel := os.Getenv("TEST_LOGGING_CONFIG"); envLevel != "" { level = envLevel } - err := loggo.ConfigureLoggers(level) + err = loggo.ConfigureLoggers(level) c.Assert(err, gc.Equals, nil) } @@ -71,8 +74,7 @@ type gocheckZapWriter struct { } func (w gocheckZapWriter) Write(buf []byte) (int, error) { - w.c.Output(1, strings.TrimSuffix(string(buf), "\n")) - return len(buf), nil + return len(buf), w.c.Output(1, strings.TrimSuffix(string(buf), "\n")) } func (w gocheckZapWriter) Sync() error { diff --git a/internal/jimmtest/mocks/jimm_controller_mock.go b/internal/jimmtest/mocks/jimm_controller_mock.go index 143dda015..a0f2aead6 100644 --- a/internal/jimmtest/mocks/jimm_controller_mock.go +++ b/internal/jimmtest/mocks/jimm_controller_mock.go @@ -1,13 +1,15 @@ +// Copyright 2024 Canonical. package mocks import ( "context" + jujuparams "github.com/juju/juju/rpc/params" + "github.com/juju/version" + "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/errors" "github.com/canonical/jimm/v3/internal/openfga" - jujuparams "github.com/juju/juju/rpc/params" - "github.com/juju/version" ) // ControllerService is an implementation of the jujuapi.ControllerService interface. diff --git a/internal/jimmtest/mocks/jimm_group_mock.go b/internal/jimmtest/mocks/jimm_group_mock.go index b93b94a4c..8065635d5 100644 --- a/internal/jimmtest/mocks/jimm_group_mock.go +++ b/internal/jimmtest/mocks/jimm_group_mock.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. // This package contains mocks for each JIMM service. // Each file contains a struct providing tests with the ability to mock diff --git a/internal/jimmtest/mocks/jimm_relation_mock.go b/internal/jimmtest/mocks/jimm_relation_mock.go index ec425ba61..06fb1652b 100644 --- a/internal/jimmtest/mocks/jimm_relation_mock.go +++ b/internal/jimmtest/mocks/jimm_relation_mock.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package mocks diff --git a/internal/jimmtest/mocks/login.go b/internal/jimmtest/mocks/login.go new file mode 100644 index 000000000..48e54779f --- /dev/null +++ b/internal/jimmtest/mocks/login.go @@ -0,0 +1,63 @@ +// Copyright 2024 Canonical. +package mocks + +import ( + "context" + "net/http" + + "golang.org/x/oauth2" + + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/openfga" +) + +type LoginService struct { + AuthenticateBrowserSession_ func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) + LoginDevice_ func(ctx context.Context) (*oauth2.DeviceAuthResponse, error) + GetDeviceSessionToken_ func(ctx context.Context, deviceOAuthResponse *oauth2.DeviceAuthResponse) (string, error) + LoginClientCredentials_ func(ctx context.Context, clientID string, clientSecret string) (*openfga.User, error) + LoginWithSessionToken_ func(ctx context.Context, sessionToken string) (*openfga.User, error) + LoginWithSessionCookie_ func(ctx context.Context, identityID string) (*openfga.User, error) +} + +func (j *LoginService) AuthenticateBrowserSession(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { + if j.AuthenticateBrowserSession_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.AuthenticateBrowserSession_(ctx, w, req) +} + +func (j *LoginService) LoginDevice(ctx context.Context) (*oauth2.DeviceAuthResponse, error) { + if j.LoginDevice_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.LoginDevice_(ctx) +} + +func (j *LoginService) GetDeviceSessionToken(ctx context.Context, deviceOAuthResponse *oauth2.DeviceAuthResponse) (string, error) { + if j.GetDeviceSessionToken_ == nil { + return "", errors.E(errors.CodeNotImplemented) + } + return j.GetDeviceSessionToken_(ctx, deviceOAuthResponse) +} + +func (j *LoginService) LoginClientCredentials(ctx context.Context, clientID string, clientSecret string) (*openfga.User, error) { + if j.LoginClientCredentials_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.LoginClientCredentials_(ctx, clientID, clientSecret) +} + +func (j *LoginService) LoginWithSessionToken(ctx context.Context, sessionToken string) (*openfga.User, error) { + if j.LoginWithSessionToken_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.LoginWithSessionToken_(ctx, sessionToken) +} + +func (j *LoginService) LoginWithSessionCookie(ctx context.Context, identityID string) (*openfga.User, error) { + if j.LoginWithSessionCookie_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.LoginWithSessionCookie_(ctx, identityID) +} diff --git a/internal/jimmtest/mocks/model.go b/internal/jimmtest/mocks/model.go new file mode 100644 index 000000000..0b0ff2536 --- /dev/null +++ b/internal/jimmtest/mocks/model.go @@ -0,0 +1,167 @@ +// Copyright 2024 Canonical. +package mocks + +import ( + "context" + "time" + + jujuparams "github.com/juju/juju/rpc/params" + "github.com/juju/names/v5" + + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/jimm" + "github.com/canonical/jimm/v3/internal/openfga" + "github.com/canonical/jimm/v3/pkg/api/params" +) + +// ModelManager defines the mock struct used to implement the ModelManger interface. +type ModelManager struct { + AddModel_ func(ctx context.Context, u *openfga.User, args *jimm.ModelCreateArgs) (*jujuparams.ModelInfo, error) + ChangeModelCredential_ func(ctx context.Context, user *openfga.User, modelTag names.ModelTag, cloudCredentialTag names.CloudCredentialTag) error + DestroyModel_ func(ctx context.Context, u *openfga.User, mt names.ModelTag, destroyStorage *bool, force *bool, maxWait *time.Duration, timeout *time.Duration) error + DumpModel_ func(ctx context.Context, u *openfga.User, mt names.ModelTag, simplified bool) (string, error) + DumpModelDB_ func(ctx context.Context, u *openfga.User, mt names.ModelTag) (map[string]interface{}, error) + ForEachModel_ func(ctx context.Context, u *openfga.User, f func(*dbmodel.Model, jujuparams.UserAccessPermission) error) error + ForEachUserModel_ func(ctx context.Context, u *openfga.User, f func(*dbmodel.Model, jujuparams.UserAccessPermission) error) error + FullModelStatus_ func(ctx context.Context, user *openfga.User, modelTag names.ModelTag, patterns []string) (*jujuparams.FullStatus, error) + ImportModel_ func(ctx context.Context, user *openfga.User, controllerName string, modelTag names.ModelTag, newOwner string) error + IdentityModelDefaults_ func(ctx context.Context, user *dbmodel.Identity) (map[string]interface{}, error) + ModelDefaultsForCloud_ func(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag) (jujuparams.ModelDefaultsResult, error) + ModelInfo_ func(ctx context.Context, u *openfga.User, mt names.ModelTag) (*jujuparams.ModelInfo, error) + ModelStatus_ func(ctx context.Context, u *openfga.User, mt names.ModelTag) (*jujuparams.ModelStatus, error) + QueryModelsJq_ func(ctx context.Context, models []string, jqQuery string) (params.CrossModelQueryResponse, error) + SetModelDefaults_ func(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag, region string, configs map[string]interface{}) error + UnsetModelDefaults_ func(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag, region string, keys []string) error + UpdateMigratedModel_ func(ctx context.Context, user *openfga.User, modelTag names.ModelTag, targetControllerName string) error + ValidateModelUpgrade_ func(ctx context.Context, u *openfga.User, mt names.ModelTag, force bool) error + WatchAllModelSummaries_ func(ctx context.Context, controller *dbmodel.Controller) (_ func() error, err error) +} + +func (j *ModelManager) AddModel(ctx context.Context, u *openfga.User, args *jimm.ModelCreateArgs) (_ *jujuparams.ModelInfo, err error) { + if j.AddModel_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.AddModel_(ctx, u, args) +} + +func (j *ModelManager) ChangeModelCredential(ctx context.Context, user *openfga.User, modelTag names.ModelTag, cloudCredentialTag names.CloudCredentialTag) error { + if j.ChangeModelCredential_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.ChangeModelCredential_(ctx, user, modelTag, cloudCredentialTag) +} + +func (j *ModelManager) DestroyModel(ctx context.Context, u *openfga.User, mt names.ModelTag, destroyStorage *bool, force *bool, maxWait *time.Duration, timeout *time.Duration) error { + if j.DestroyModel_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.DestroyModel_(ctx, u, mt, destroyStorage, force, maxWait, timeout) +} + +func (j *ModelManager) DumpModel(ctx context.Context, u *openfga.User, mt names.ModelTag, simplified bool) (string, error) { + if j.DumpModel_ == nil { + return "", errors.E(errors.CodeNotImplemented) + } + return j.DumpModel_(ctx, u, mt, simplified) +} +func (j *ModelManager) DumpModelDB(ctx context.Context, u *openfga.User, mt names.ModelTag) (map[string]interface{}, error) { + if j.DumpModelDB_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.DumpModelDB_(ctx, u, mt) +} + +func (j *ModelManager) ForEachModel(ctx context.Context, u *openfga.User, f func(*dbmodel.Model, jujuparams.UserAccessPermission) error) error { + if j.ForEachModel_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.ForEachModel_(ctx, u, f) +} + +func (j *ModelManager) ForEachUserModel(ctx context.Context, u *openfga.User, f func(*dbmodel.Model, jujuparams.UserAccessPermission) error) error { + if j.ForEachUserModel_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.ForEachUserModel_(ctx, u, f) +} + +func (j *ModelManager) FullModelStatus(ctx context.Context, user *openfga.User, modelTag names.ModelTag, patterns []string) (*jujuparams.FullStatus, error) { + if j.FullModelStatus_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.FullModelStatus_(ctx, user, modelTag, patterns) +} + +func (j *ModelManager) ImportModel(ctx context.Context, user *openfga.User, controllerName string, modelTag names.ModelTag, newOwner string) error { + if j.ImportModel_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.ImportModel_(ctx, user, controllerName, modelTag, newOwner) +} + +func (j *ModelManager) ModelDefaultsForCloud(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag) (jujuparams.ModelDefaultsResult, error) { + if j.ModelDefaultsForCloud_ == nil { + return jujuparams.ModelDefaultsResult{}, errors.E(errors.CodeNotImplemented) + } + return j.ModelDefaultsForCloud_(ctx, user, cloudTag) +} + +func (j *ModelManager) ModelInfo(ctx context.Context, u *openfga.User, mt names.ModelTag) (*jujuparams.ModelInfo, error) { + if j.ModelInfo_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.ModelInfo_(ctx, u, mt) +} +func (j *ModelManager) ModelStatus(ctx context.Context, u *openfga.User, mt names.ModelTag) (*jujuparams.ModelStatus, error) { + if j.ModelStatus_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.ModelStatus_(ctx, u, mt) +} + +func (j *ModelManager) QueryModelsJq(ctx context.Context, models []string, jqQuery string) (params.CrossModelQueryResponse, error) { + if j.QueryModelsJq_ == nil { + return params.CrossModelQueryResponse{}, errors.E(errors.CodeNotImplemented) + } + return j.QueryModelsJq_(ctx, models, jqQuery) +} + +func (j *ModelManager) SetModelDefaults(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag, region string, configs map[string]interface{}) error { + if j.SetModelDefaults_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.SetModelDefaults_(ctx, user, cloudTag, region, configs) +} + +func (j *ModelManager) UnsetModelDefaults(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag, region string, keys []string) error { + if j.UnsetModelDefaults_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.UnsetModelDefaults_(ctx, user, cloudTag, region, keys) +} + +func (j *ModelManager) UpdateMigratedModel(ctx context.Context, user *openfga.User, modelTag names.ModelTag, targetControllerName string) error { + if j.UpdateMigratedModel_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.UpdateMigratedModel_(ctx, user, modelTag, targetControllerName) +} +func (j *ModelManager) IdentityModelDefaults(ctx context.Context, user *dbmodel.Identity) (map[string]interface{}, error) { + if j.IdentityModelDefaults_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.IdentityModelDefaults_(ctx, user) +} +func (j *ModelManager) ValidateModelUpgrade(ctx context.Context, u *openfga.User, mt names.ModelTag, force bool) error { + if j.ValidateModelUpgrade_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.ValidateModelUpgrade_(ctx, u, mt, force) +} +func (j *ModelManager) WatchAllModelSummaries(ctx context.Context, controller *dbmodel.Controller) (_ func() error, err error) { + if j.WatchAllModelSummaries_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.WatchAllModelSummaries_(ctx, controller) +} diff --git a/internal/jimmtest/openfga.go b/internal/jimmtest/openfga.go index ff8eba74b..a2ddd5d0a 100644 --- a/internal/jimmtest/openfga.go +++ b/internal/jimmtest/openfga.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package jimmtest import ( @@ -161,7 +162,14 @@ func TruncateOpenFgaTuples(ctx context.Context) error { return errors.E(err) } defer conn.Close(ctx) - conn.Exec(ctx, "TRUNCATE TABLE tuple;") - conn.Exec(ctx, "TRUNCATE TABLE changelog;") + + if _, err := conn.Exec(ctx, "TRUNCATE TABLE tuple;"); err != nil { + return err + } + + if _, err := conn.Exec(ctx, "TRUNCATE TABLE changelog;"); err != nil { + return err + } + return nil } diff --git a/internal/jimmtest/store.go b/internal/jimmtest/store.go index a110bc36b..5ba28781c 100644 --- a/internal/jimmtest/store.go +++ b/internal/jimmtest/store.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package jimmtest import ( @@ -93,7 +94,7 @@ func (s *InMemoryCredentialStore) PutControllerCredentials(ctx context.Context, if s.controllerCredentials == nil { s.controllerCredentials = map[string]controllerCredentials{ - controllerName: controllerCredentials{ + controllerName: { username: username, password: password, }, @@ -136,7 +137,7 @@ func (s *InMemoryCredentialStore) GetJWKSPrivateKey(ctx context.Context) ([]byte s.mu.RLock() defer s.mu.RUnlock() - if s.privateKey == nil || len(s.privateKey) == 0 { + if len(s.privateKey) == 0 { return nil, errors.E(errors.CodeNotFound) } diff --git a/internal/jimmtest/suite.go b/internal/jimmtest/suite.go index 84c2ac690..925acd114 100644 --- a/internal/jimmtest/suite.go +++ b/internal/jimmtest/suite.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jimmtest diff --git a/internal/jimmtest/vault.go b/internal/jimmtest/vault.go index b886c83c6..884761afe 100644 --- a/internal/jimmtest/vault.go +++ b/internal/jimmtest/vault.go @@ -1,14 +1,16 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jimmtest import ( - "encoding/json" - - vault_test "github.com/canonical/jimm/v3/local/vault" "github.com/hashicorp/vault/api" ) +const ( + testRoleID = "test-role-id" + testSecretID = "test-secret-id" +) + type fatalF interface { Name() string Fatalf(format string, args ...interface{}) @@ -19,29 +21,5 @@ func VaultClient(tb fatalF) (*api.Client, string, string, string, bool) { cfg := api.DefaultConfig() cfg.Address = "http://localhost:8200" vaultClient, _ := api.NewClient(cfg) - - appRole := vault_test.AppRole - var vaultAPISecret api.Secret - err := json.Unmarshal(appRole, &vaultAPISecret) - if err != nil { - panic("cannot unmarshal vault secret") - } - - roleID, ok := vaultAPISecret.Data["role_id"] - if !ok { - panic("role ID not found") - } - roleSecretID, ok := vaultAPISecret.Data["secret_id"] - if !ok { - panic("role secret ID not found") - } - roleIDString, ok := roleID.(string) - if !ok { - panic("failed to convert role ID to string") - } - roleSecretIDString, ok := roleSecretID.(string) - if !ok { - panic("failed to convert role secret ID to string") - } - return vaultClient, "jimm-kv", roleIDString, roleSecretIDString, true + return vaultClient, "jimm-kv", testRoleID, testSecretID, true } diff --git a/internal/jujuapi/access_control.go b/internal/jujuapi/access_control.go index cf07bcb9e..b4de5f247 100644 --- a/internal/jujuapi/access_control.go +++ b/internal/jujuapi/access_control.go @@ -1,10 +1,9 @@ -// Copyright 2023 canonical. +// Copyright 2024 Canonical. package jujuapi import ( "context" - "regexp" "strconv" "time" @@ -22,32 +21,6 @@ import ( // access_control contains the primary RPC commands for handling ReBAC within JIMM via the JIMM facade itself. -var ( - // Matches juju uris, jimm user/group tags and UUIDs - // Performs a single match and breaks the juju URI into 10 groups, each successive group is XORD to ensure we can run - // this just once. - // The groups are as so: - // [0] - Entire match - // [1] - tag - // [2] - A single "-", ignored - // [3] - Controller name OR user name OR group name - // [4] - A single ":", ignored - // [5] - Controller user / model owner - // [6] - A single "/", ignored - // [7] - Model name - // [8] - A single ".", ignored - // [9] - Application offer name - // [10] - Relation specifier (i.e., #member) - // A complete matcher example would look like so with square-brackets denoting groups and paranthsis denoting index: - // (1)[controller](2)[-](3)[controller-1](4)[:](5)[alice@canonical.com-place](6)[/](7)[model-1](8)[.](9)[offer-1](10)[#relation-specifier]" - // In the case of something like: user-alice@wonderland or group-alices-wonderland#member, it would look like so: - // (1)[user](2)[-](3)[alices@wonderland] - // (1)[group](2)[-](3)[alices-wonderland](10)[#member] - // So if a group, user, UUID, controller name comes in, it will always be index 3 for them - // and if a relation specifier is present, it will always be index 10 - jujuURIMatcher = regexp.MustCompile(`([a-zA-Z0-9]*)(\-|\z)([a-zA-Z0-9-@.]*)(\:|)([a-zA-Z0-9-@]*)(\/|)([a-zA-Z0-9-]*)(\.|)([a-zA-Z0-9-]*)([a-zA-Z#]*|\z)\z`) -) - const ( jimmControllerName = "jimm" ) diff --git a/internal/jujuapi/access_control_test.go b/internal/jujuapi/access_control_test.go index a25951548..e1230590b 100644 --- a/internal/jujuapi/access_control_test.go +++ b/internal/jujuapi/access_control_test.go @@ -1,11 +1,10 @@ -// Copyright 2023 canonical. +// Copyright 2024 Canonical. package jujuapi_test import ( "context" "database/sql" - "strconv" "time" petname "github.com/dustinkirkland/golang-petname" @@ -89,15 +88,17 @@ func (s *accessControlSuite) TestRemoveGroupRemovesTuples(c *gc.C) { user, group, controller, model, _, _, _, client, closeClient := createTestControllerEnvironment(ctx, c, s) defer closeClient() - db.AddGroup(ctx, "test-group2") + _, err := db.AddGroup(ctx, "test-group2") + c.Assert(err, gc.IsNil) + group2 := &dbmodel.GroupEntry{ Name: "test-group2", } - err := db.GetGroup(ctx, group2) + err = db.GetGroup(ctx, group2) c.Assert(err, gc.IsNil) tuples := []openfga.Tuple{ - //This tuple should remain as it has no relation to group2 + // This tuple should remain as it has no relation to group2 { Object: ofganames.ConvertTag(user.ResourceTag()), Relation: "member", @@ -133,7 +134,7 @@ func (s *accessControlSuite) TestRemoveGroupRemovesTuples(c *gc.C) { err = s.JIMM.OpenFGAClient.AddRelation(context.Background(), tuples...) c.Assert(err, gc.IsNil) - //Check user has access to model and controller through group2 + // Check user has access to model and controller through group2 checkResp, err := client.CheckRelation(&apiparams.CheckRelationRequest{Tuple: checkAccessTupleController}) c.Assert(err, gc.IsNil) c.Assert(checkResp.Allowed, gc.Equals, true) @@ -148,7 +149,7 @@ func (s *accessControlSuite) TestRemoveGroupRemovesTuples(c *gc.C) { c.Assert(err, gc.IsNil) c.Assert(len(resp.Tuples), gc.Equals, 13) - //Check user access has been revoked. + // Check user access has been revoked. checkResp, err = client.CheckRelation(&apiparams.CheckRelationRequest{Tuple: checkAccessTupleController}) c.Assert(err, gc.IsNil) c.Assert(checkResp.Allowed, gc.Equals, false) @@ -224,10 +225,6 @@ func createTuple(object, relation, target string) openfga.Tuple { } } -func stringGroupID(id uint) string { - return strconv.FormatUint(uint64(id), 10) -} - // TestAddRelation currently verifies the following test cases, // when new relation control is to be added, please update this comment: // user -> group @@ -251,11 +248,13 @@ func (s *accessControlSuite) TestAddRelation(c *gc.C) { user, group, controller, model, offer, _, _, client, closeClient := createTestControllerEnvironment(ctx, c, s) defer closeClient() - db.AddGroup(ctx, "test-group2") + _, err := db.AddGroup(ctx, "test-group2") + c.Assert(err, gc.IsNil) + group2 := &dbmodel.GroupEntry{ Name: "test-group2", } - err := db.GetGroup(ctx, group2) + err = db.GetGroup(ctx, group2) c.Assert(err, gc.IsNil) c.Assert(err, gc.IsNil) @@ -305,7 +304,7 @@ func (s *accessControlSuite) TestAddRelation(c *gc.C) { err: false, changesType: "controller", }, - //Test user -> group + // Test user -> group { input: tuple{"user-" + user.Name, "member", "group-" + group.Name}, want: createTuple( @@ -316,7 +315,7 @@ func (s *accessControlSuite) TestAddRelation(c *gc.C) { err: false, changesType: "group", }, - //Test username with dots and @ -> group + // Test username with dots and @ -> group { input: tuple{"user-" + "kelvin.lina.test@canonical.com", "member", "group-" + group.Name}, want: createTuple( @@ -327,7 +326,7 @@ func (s *accessControlSuite) TestAddRelation(c *gc.C) { err: false, changesType: "group", }, - //Test group -> controller + // Test group -> controller { input: tuple{"group-" + "test-group#member", "administrator", "controller-" + controller.UUID}, want: createTuple( @@ -338,9 +337,9 @@ func (s *accessControlSuite) TestAddRelation(c *gc.C) { err: false, changesType: "controller", }, - //Test user -> model by name + // Test user -> model by name { - input: tuple{"user-" + user.Name, "writer", "model-" + controller.Name + ":" + user.Name + "/" + model.Name}, + input: tuple{"user-" + user.Name, "writer", "model-" + user.Name + "/" + model.Name}, want: createTuple( "user:"+user.Name, "writer", @@ -362,7 +361,7 @@ func (s *accessControlSuite) TestAddRelation(c *gc.C) { }, // Test user -> applicationoffer by name { - input: tuple{"user-" + user.Name, "consumer", "applicationoffer-" + controller.Name + ":" + user.Name + "/" + model.Name + "." + offer.Name}, + input: tuple{"user-" + user.Name, "consumer", "applicationoffer-" + offer.URL}, want: createTuple( "user:"+user.Name, "consumer", @@ -406,7 +405,7 @@ func (s *accessControlSuite) TestAddRelation(c *gc.C) { }, // Test group -> model by name { - input: tuple{"group-" + group.Name + "#member", "writer", "model-" + controller.Name + ":" + user.Name + "/" + model.Name}, + input: tuple{"group-" + group.Name + "#member", "writer", "model-" + user.Name + "/" + model.Name}, want: createTuple( "group:"+group.UUID+"#member", "writer", @@ -428,7 +427,7 @@ func (s *accessControlSuite) TestAddRelation(c *gc.C) { }, // Test group -> applicationoffer by name { - input: tuple{"group-" + group.Name + "#member", "consumer", "applicationoffer-" + controller.Name + ":" + user.Name + "/" + model.Name + "." + offer.Name}, + input: tuple{"group-" + group.Name + "#member", "consumer", "applicationoffer-" + offer.URL}, want: createTuple( "group:"+group.UUID+"#member", "consumer", @@ -464,6 +463,10 @@ func (s *accessControlSuite) TestAddRelation(c *gc.C) { for i, tc := range tagTests { c.Logf("running test %d", i) if i != 0 { + // Needed due to removing original added relations for this test. + // Without, we cannot add the relations. + // + //nolint:errcheck s.COFGAClient.RemoveRelation(ctx, tc.want) } err := client.AddRelation(&apiparams.AddRelationRequest{ @@ -559,7 +562,7 @@ func (s *accessControlSuite) TestRemoveRelation(c *gc.C) { err: false, changesType: "controller", }, - //Test user -> group + // Test user -> group { toAdd: openfga.Tuple{ Object: ofganames.ConvertTag(user.ResourceTag()), @@ -575,7 +578,7 @@ func (s *accessControlSuite) TestRemoveRelation(c *gc.C) { err: false, changesType: "group", }, - //Test group -> controller + // Test group -> controller { toAdd: openfga.Tuple{ Object: ofganames.ConvertTagWithRelation(group.ResourceTag(), ofganames.MemberRelation), @@ -591,14 +594,14 @@ func (s *accessControlSuite) TestRemoveRelation(c *gc.C) { err: false, changesType: "controller", }, - //Test user -> model by name + // Test user -> model by name { toAdd: openfga.Tuple{ Object: ofganames.ConvertTag(user.ResourceTag()), Relation: "writer", Target: ofganames.ConvertTag(model.ResourceTag()), }, - toRemove: tuple{"user-" + user.Name, "writer", "model-" + controller.Name + ":" + user.Name + "/" + model.Name}, + toRemove: tuple{"user-" + user.Name, "writer", "model-" + user.Name + "/" + model.Name}, want: createTuple( "user:"+user.Name, "writer", @@ -630,7 +633,7 @@ func (s *accessControlSuite) TestRemoveRelation(c *gc.C) { Relation: "consumer", Target: ofganames.ConvertTag(offer.ResourceTag()), }, - toRemove: tuple{"user-" + user.Name, "consumer", "applicationoffer-" + controller.Name + ":" + user.Name + "/" + model.Name + "." + offer.Name}, + toRemove: tuple{"user-" + user.Name, "consumer", "applicationoffer-" + offer.URL}, want: createTuple( "user:"+user.Name, "consumer", @@ -694,7 +697,7 @@ func (s *accessControlSuite) TestRemoveRelation(c *gc.C) { Relation: "writer", Target: ofganames.ConvertTag(model.ResourceTag()), }, - toRemove: tuple{"group-" + group.Name + "#member", "writer", "model-" + controller.Name + ":" + user.Name + "/" + model.Name}, + toRemove: tuple{"group-" + group.Name + "#member", "writer", "model-" + user.Name + "/" + model.Name}, want: createTuple( "group:"+group.UUID+"#member", "writer", @@ -726,7 +729,7 @@ func (s *accessControlSuite) TestRemoveRelation(c *gc.C) { Relation: "consumer", Target: ofganames.ConvertTag(offer.ResourceTag()), }, - toRemove: tuple{"group-" + group.Name + "#member", "consumer", "applicationoffer-" + controller.Name + ":" + user.Name + "/" + model.Name + "." + offer.Name}, + toRemove: tuple{"group-" + group.Name + "#member", "consumer", "applicationoffer-" + offer.URL}, want: createTuple( "group:"+group.UUID+"#member", "consumer", @@ -818,10 +821,10 @@ func (s *accessControlSuite) TestJAASTag(c *gc.C) { expectedJAASTag: "controller-" + controller.Name, }, { tag: ofganames.ConvertTag(model.ResourceTag()), - expectedJAASTag: "model-" + controller.Name + ":" + user.Name + "/" + model.Name, + expectedJAASTag: "model-" + user.Name + "/" + model.Name, }, { tag: ofganames.ConvertTag(applicationOffer.ResourceTag()), - expectedJAASTag: "applicationoffer-" + controller.Name + ":" + user.Name + "/" + model.Name + "." + applicationOffer.Name, + expectedJAASTag: "applicationoffer-" + applicationOffer.URL, }, { tag: &ofganames.Tag{}, expectedError: "unexpected tag kind: ", @@ -890,7 +893,7 @@ func (s *accessControlSuite) TestJAASTagNoUUIDResolution(c *gc.C) { func (s *accessControlSuite) TestListRelationshipTuples(c *gc.C) { ctx := context.Background() - user, _, controller, model, applicationOffer, _, _, client, closeClient := createTestControllerEnvironment(ctx, c, s) + user, _, controller, _, applicationOffer, _, _, client, closeClient := createTestControllerEnvironment(ctx, c, s) defer closeClient() _, err := client.AddGroup(&apiparams.AddGroupRequest{Name: "yellow"}) @@ -913,7 +916,7 @@ func (s *accessControlSuite) TestListRelationshipTuples(c *gc.C) { }, { Object: "group-orange#member", Relation: "administrator", - TargetObject: "applicationoffer-" + controller.Name + ":" + user.Name + "/" + model.Name + "." + applicationOffer.Name, + TargetObject: "applicationoffer-" + applicationOffer.URL, }} err = client.AddRelation(&apiparams.AddRelationRequest{Tuples: tuples}) @@ -927,18 +930,27 @@ func (s *accessControlSuite) TestListRelationshipTuples(c *gc.C) { response, err = client.ListRelationshipTuples(&apiparams.ListRelationshipTuplesRequest{ Tuple: apiparams.RelationshipTuple{ - TargetObject: "applicationoffer-" + controller.Name + ":" + user.Name + "/" + model.Name + "." + applicationOffer.Name, + TargetObject: "applicationoffer-" + applicationOffer.URL, }, ResolveUUIDs: true, }) c.Assert(err, jc.ErrorIsNil) c.Assert(response.Tuples, jc.DeepEquals, []apiparams.RelationshipTuple{tuples[3]}) c.Assert(len(response.Errors), gc.Equals, 0) + + // Test error message when a resource is not found + _, err = client.ListRelationshipTuples(&apiparams.ListRelationshipTuplesRequest{ + Tuple: apiparams.RelationshipTuple{ + TargetObject: "applicationoffer-" + "fake-offer", + }, + ResolveUUIDs: true, + }) + c.Assert(err, gc.ErrorMatches, "failed to parse tuple target object key applicationoffer-fake-offer: application offer not found.*") } func (s *accessControlSuite) TestListRelationshipTuplesNoUUIDResolution(c *gc.C) { ctx := context.Background() - user, _, controller, model, applicationOffer, _, _, client, closeClient := createTestControllerEnvironment(ctx, c, s) + _, _, _, _, applicationOffer, _, _, client, closeClient := createTestControllerEnvironment(ctx, c, s) defer closeClient() _, err := client.AddGroup(&apiparams.AddGroupRequest{Name: "orange"}) @@ -963,7 +975,7 @@ func (s *accessControlSuite) TestListRelationshipTuplesNoUUIDResolution(c *gc.C) }} response, err := client.ListRelationshipTuples(&apiparams.ListRelationshipTuplesRequest{ Tuple: apiparams.RelationshipTuple{ - TargetObject: "applicationoffer-" + controller.Name + ":" + user.Name + "/" + model.Name + "." + applicationOffer.Name, + TargetObject: "applicationoffer-" + applicationOffer.URL, }, ResolveUUIDs: false, }) @@ -974,7 +986,7 @@ func (s *accessControlSuite) TestListRelationshipTuplesNoUUIDResolution(c *gc.C) func (s *accessControlSuite) TestListRelationshipTuplesAfterDeletingGroup(c *gc.C) { ctx := context.Background() - user, _, controller, model, applicationOffer, _, _, client, closeClient := createTestControllerEnvironment(ctx, c, s) + user, _, controller, _, applicationOffer, _, _, client, closeClient := createTestControllerEnvironment(ctx, c, s) defer closeClient() _, err := client.AddGroup(&apiparams.AddGroupRequest{Name: "yellow"}) @@ -997,7 +1009,7 @@ func (s *accessControlSuite) TestListRelationshipTuplesAfterDeletingGroup(c *gc. }, { Object: "group-orange#member", Relation: "administrator", - TargetObject: "applicationoffer-" + controller.Name + ":" + user.Name + "/" + model.Name + "." + applicationOffer.Name, + TargetObject: "applicationoffer-" + applicationOffer.URL, }} err = client.AddRelation(&apiparams.AddRelationRequest{Tuples: tuples}) @@ -1009,9 +1021,18 @@ func (s *accessControlSuite) TestListRelationshipTuplesAfterDeletingGroup(c *gc. response, err := client.ListRelationshipTuples(&apiparams.ListRelationshipTuplesRequest{ResolveUUIDs: true}) c.Assert(err, jc.ErrorIsNil) // Create a new slice of tuples excluding the ones we expect to be deleted. - newTuples := []apiparams.RelationshipTuple{tuples[1], tuples[3]} - // first three tuples created during setup test - c.Assert(response.Tuples[12:], jc.DeepEquals, newTuples) + responseTuples := response.Tuples[12:] + c.Assert(responseTuples, gc.HasLen, 2) + + expectedUserToGroupTuple := tuples[1] + expectedGroupToOfferTuple := tuples[3] + + // Update the target to the group name + expectedUserToGroupTuple.TargetObject = "group-orange" + c.Assert(responseTuples[0], gc.DeepEquals, expectedUserToGroupTuple) + expectedGroupToOfferTuple.Object = "group-orange#member" + c.Assert(responseTuples[1], gc.DeepEquals, expectedGroupToOfferTuple) + c.Assert(len(response.Errors), gc.Equals, 0) } @@ -1082,7 +1103,7 @@ func (s *accessControlSuite) TestCheckRelationOfferReaderFlow(c *gc.C) { ctx := context.Background() ofgaClient := s.JIMM.OpenFGAClient - user, group, controller, model, offer, _, _, client, closeClient := createTestControllerEnvironment(ctx, c, s) + user, group, _, _, offer, _, _, client, closeClient := createTestControllerEnvironment(ctx, c, s) defer closeClient() // Some tags (tuples) to assist in the creation of tuples within OpenFGA (such that they can be tested against) @@ -1092,7 +1113,7 @@ func (s *accessControlSuite) TestCheckRelationOfferReaderFlow(c *gc.C) { // JAAS style keys, to be translated and checked against UUIDs/users/groups userJAASKey := "user-" + user.Name - offerJAASKey := "applicationoffer-" + controller.Name + ":" + user.Name + "/" + model.Name + "." + offer.Name + offerJAASKey := "applicationoffer-" + offer.URL // Test direct relation to an applicationoffer from a user of a group via "reader" relation @@ -1153,7 +1174,7 @@ func (s *accessControlSuite) TestCheckRelationOfferConsumerFlow(c *gc.C) { ctx := context.Background() ofgaClient := s.JIMM.OpenFGAClient - user, group, controller, model, offer, _, _, client, closeClient := createTestControllerEnvironment(ctx, c, s) + user, group, _, _, offer, _, _, client, closeClient := createTestControllerEnvironment(ctx, c, s) defer closeClient() // Some keys to assist in the creation of tuples within OpenFGA (such that they can be tested against) @@ -1163,7 +1184,7 @@ func (s *accessControlSuite) TestCheckRelationOfferConsumerFlow(c *gc.C) { // JAAS style keys, to be translated and checked against UUIDs/users/groups userJAASKey := "user-" + user.Name - offerJAASKey := "applicationoffer-" + controller.Name + ":" + user.Name + "/" + model.Name + "." + offer.Name + offerJAASKey := "applicationoffer-" + offer.URL // Test direct relation to an applicationoffer from a user of a group via "consumer" relation userToGroupMember := openfga.Tuple{ @@ -1222,7 +1243,7 @@ func (s *accessControlSuite) TestCheckRelationModelReaderFlow(c *gc.C) { ctx := context.Background() ofgaClient := s.JIMM.OpenFGAClient - user, group, controller, model, _, _, _, client, closeClient := createTestControllerEnvironment(ctx, c, s) + user, group, _, model, _, _, _, client, closeClient := createTestControllerEnvironment(ctx, c, s) defer closeClient() // Some keys to assist in the creation of tuples within OpenFGA (such that they can be tested against) @@ -1234,7 +1255,7 @@ func (s *accessControlSuite) TestCheckRelationModelReaderFlow(c *gc.C) { // JAAS style keys, to be translated and checked against UUIDs/users/groups userJAASKey := "user-" + user.Name - modelJAASKey := "model-" + controller.Name + ":" + user.Name + "/" + model.Name + modelJAASKey := "model-" + user.Name + "/" + model.Name // Test direct relation to a model from a user of a group via "reader" relation userToGroupMember := openfga.Tuple{ @@ -1293,7 +1314,7 @@ func (s *accessControlSuite) TestCheckRelationModelWriterFlow(c *gc.C) { ctx := context.Background() ofgaClient := s.JIMM.OpenFGAClient - user, group, controller, model, _, _, _, client, closeClient := createTestControllerEnvironment(ctx, c, s) + user, group, _, model, _, _, _, client, closeClient := createTestControllerEnvironment(ctx, c, s) defer closeClient() // Some keys to assist in the creation of tuples within OpenFGA (such that they can be tested against) @@ -1315,7 +1336,7 @@ func (s *accessControlSuite) TestCheckRelationModelWriterFlow(c *gc.C) { // JAAS style keys, to be translated and checked against UUIDs/users/groups userJAASKey := "user-" + user.Name - modelJAASKey := "model-" + controller.Name + ":" + user.Name + "/" + model.Name + modelJAASKey := "model-" + user.Name + "/" + model.Name err := ofgaClient.AddRelation( ctx, @@ -1376,8 +1397,8 @@ func (s *accessControlSuite) TestCheckRelationControllerAdministratorFlow(c *gc. userJAASKey := "user-" + user.Name groupJAASKey := "group-" + group.Name controllerJAASKey := "controller-" + controller.Name - modelJAASKey := "model-" + controller.Name + ":" + user.Name + "/" + model.Name - offerJAASKey := "applicationoffer-" + controller.Name + ":" + user.Name + "/" + model.Name + "." + offer.Name + modelJAASKey := "model-" + user.Name + "/" + model.Name + offerJAASKey := "applicationoffer-" + offer.URL // Test the administrator flow of a group user being related to a controller via administrator relation userToGroup := openfga.Tuple{ diff --git a/internal/jujuapi/admin.go b/internal/jujuapi/admin.go index 5b75c2224..dd9be25ab 100644 --- a/internal/jujuapi/admin.go +++ b/internal/jujuapi/admin.go @@ -1,24 +1,38 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi import ( "context" - stderrors "errors" + "net/http" "sort" "github.com/juju/juju/rpc" jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" + "golang.org/x/oauth2" - "github.com/canonical/jimm/v3/internal/auth" "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/jimm" "github.com/canonical/jimm/v3/internal/openfga" "github.com/canonical/jimm/v3/pkg/api/params" - jimmnames "github.com/canonical/jimm/v3/pkg/names" ) +// LoginService defines the set of methods used for login to JIMM. +type LoginService interface { + // AuthenticateBrowserSession authenticates a session cookie is valid. + AuthenticateBrowserSession(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, error) + // LoginDevice is step 1 in the device flow and returns the OIDC server that the client should use for login. + LoginDevice(ctx context.Context) (*oauth2.DeviceAuthResponse, error) + // GetDeviceSessionToken polls the OIDC server waiting for the client to login and return a user scoped session token. + GetDeviceSessionToken(ctx context.Context, deviceOAuthResponse *oauth2.DeviceAuthResponse) (string, error) + // LoginWithClientCredentials verifies a user by their client credentials. + LoginClientCredentials(ctx context.Context, clientID string, clientSecret string) (*openfga.User, error) + // LoginWithSessionToken verifies a user based on their session token. + LoginWithSessionToken(ctx context.Context, sessionToken string) (*openfga.User, error) + // LoginWithSessionCookie verifies a user based on an identity from a cookie obtained during websocket upgrade. + LoginWithSessionCookie(ctx context.Context, identityID string) (*openfga.User, error) +} + // unsupportedLogin returns an appropriate error for login attempts using // old version of the Admin facade. func unsupportedLogin() error { @@ -39,9 +53,9 @@ func (r *controllerRoot) LoginDevice(ctx context.Context) (params.LoginDeviceRes const op = errors.Op("jujuapi.LoginDevice") response := params.LoginDeviceResponse{} - deviceResponse, err := jimm.LoginDevice(ctx, r.jimm.OAuthAuthenticationService()) + deviceResponse, err := r.jimm.LoginDevice(ctx) if err != nil { - return response, errors.E(op, err) + return response, errors.E(op, err, errors.CodeUnauthorized) } // NOTE: As this is on the controller root struct, and a new controller root // is created per WS, it is EXPECTED that the subsequent call to GetDeviceSessionToken @@ -63,9 +77,9 @@ func (r *controllerRoot) GetDeviceSessionToken(ctx context.Context) (params.GetD const op = errors.Op("jujuapi.GetDeviceSessionToken") response := params.GetDeviceSessionTokenResponse{} - token, err := jimm.GetDeviceSessionToken(ctx, r.jimm.OAuthAuthenticationService(), r.jimm.GetCredentialStore(), r.deviceOAuthResponse) + token, err := r.jimm.GetDeviceSessionToken(ctx, r.deviceOAuthResponse) if err != nil { - return response, errors.E(op, err) + return response, errors.E(op, err, errors.CodeUnauthorized) } response.SessionToken = token @@ -81,19 +95,9 @@ func (r *controllerRoot) GetDeviceSessionToken(ctx context.Context) (params.GetD func (r *controllerRoot) LoginWithSessionCookie(ctx context.Context) (jujuparams.LoginResult, error) { const op = errors.Op("jujuapi.LoginWithSessionCookie") - // If no identity ID has come through, then no cookie was present - // and as such authentication has failed. - if r.identityId == "" { - return jujuparams.LoginResult{}, errors.E(op, &auth.AuthenticationError{}) - } - - user, err := r.jimm.GetUser(ctx, r.identityId) - if err != nil { - return jujuparams.LoginResult{}, errors.E(op, err) - } - err = r.jimm.UpdateUserLastLogin(ctx, r.identityId) + user, err := r.jimm.LoginWithSessionCookie(ctx, r.identityId) if err != nil { - return jujuparams.LoginResult{}, errors.E(op, err) + return jujuparams.LoginResult{}, errors.E(op, err, errors.CodeUnauthorized) } r.mu.Lock() @@ -122,36 +126,12 @@ func (r *controllerRoot) LoginWithSessionCookie(ctx context.Context) (jujuparams // such that subsequent facade method calls can access the authenticated user. func (r *controllerRoot) LoginWithSessionToken(ctx context.Context, req params.LoginWithSessionTokenRequest) (jujuparams.LoginResult, error) { const op = errors.Op("jujuapi.LoginWithSessionToken") - authenticationSvc := r.jimm.OAuthAuthenticationService() - // Verify the session token - jwtToken, err := authenticationSvc.VerifySessionToken(req.SessionToken) + user, err := r.jimm.LoginWithSessionToken(ctx, req.SessionToken) if err != nil { - var aerr *auth.AuthenticationError - if stderrors.As(err, &aerr) { - return aerr.LoginResult, nil - } return jujuparams.LoginResult{}, errors.E(op, err, errors.CodeUnauthorized) } - // Get an OpenFGA user to place on the controllerRoot for this WS - // such that: - // - // - Subsequent calls are aware of the user - // - Authorisation checks are done against the openfga.User - email := jwtToken.Subject() - - // At this point, we know the user exists, so simply just get - // the user to create the session token. - user, err := r.jimm.GetUser(ctx, email) - if err != nil { - return jujuparams.LoginResult{}, errors.E(op, err) - } - err = r.jimm.UpdateUserLastLogin(ctx, email) - if err != nil { - return jujuparams.LoginResult{}, errors.E(op, err) - } - // TODO(ale8k): This isn't needed I don't think as controller roots are unique // per WS, but if anyone knows different please let me know. r.mu.Lock() @@ -178,29 +158,11 @@ func (r *controllerRoot) LoginWithSessionToken(ctx context.Context, req params.L func (r *controllerRoot) LoginWithClientCredentials(ctx context.Context, req params.LoginWithClientCredentialsRequest) (jujuparams.LoginResult, error) { const op = errors.Op("jujuapi.LoginWithClientCredentials") - clientIdWithDomain, err := jimmnames.EnsureValidServiceAccountId(req.ClientID) - if err != nil { - return jujuparams.LoginResult{}, errors.E("invalid client ID") - } - - authenticationSvc := r.jimm.OAuthAuthenticationService() - if authenticationSvc == nil { - return jujuparams.LoginResult{}, errors.E("authentication service not specified") - } - err = authenticationSvc.VerifyClientCredentials(ctx, req.ClientID, req.ClientSecret) + user, err := r.jimm.LoginClientCredentials(ctx, req.ClientID, req.ClientSecret) if err != nil { return jujuparams.LoginResult{}, errors.E(err, errors.CodeUnauthorized) } - user, err := r.jimm.GetUser(ctx, clientIdWithDomain) - if err != nil { - return jujuparams.LoginResult{}, errors.E(op, err) - } - err = r.jimm.UpdateUserLastLogin(ctx, clientIdWithDomain) - if err != nil { - return jujuparams.LoginResult{}, errors.E(op, err) - } - r.mu.Lock() r.user = user r.mu.Unlock() diff --git a/internal/jujuapi/admin_test.go b/internal/jujuapi/admin_test.go index 3e4701ed1..f07b6d589 100644 --- a/internal/jujuapi/admin_test.go +++ b/internal/jujuapi/admin_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi_test @@ -175,11 +175,7 @@ func (s *adminSuite) TestBrowserLoginNoCookie(c *gc.C) { lr := &jujuparams.LoginResult{} err := conn.APICall("Admin", 4, "", "LoginWithSessionCookie", nil, lr) - c.Assert( - err, - gc.ErrorMatches, - "authentication failed", - ) + c.Assert(err, gc.ErrorMatches, `missing cookie identity \(unauthorized access\)`) } // TestDeviceLogin takes a test user through the flow of logging into jimm @@ -266,6 +262,8 @@ func (s *adminSuite) TestDeviceLogin(c *gc.C) { c.Assert(err, gc.ErrorMatches, "failed to decode token.*") // Test token base64 encoded passes authentication + // + //nolint:gosimple err = conn.APICall("Admin", 4, "", "LoginWithSessionToken", params.LoginWithSessionTokenRequest{SessionToken: sessionTokenResp.SessionToken}, &loginResult) c.Assert(err, gc.IsNil) c.Assert(loginResult.UserInfo.Identity, gc.Equals, "user-"+user.Email) @@ -336,7 +334,8 @@ func (s *adminSuite) TestLoginWithClientCredentials(c *gc.C) { const ( // these are valid client credentials hardcoded into the jimm realm - validClientID = "test-client-id" + validClientID = "test-client-id" + //nolint:gosec // Thinks credentials hardcoded. validClientSecret = "2M2blFbO4GX4zfggQpivQSxwWX1XGgNf" ) diff --git a/internal/jujuapi/api.go b/internal/jujuapi/api.go index 82dbd090d..188b553a5 100644 --- a/internal/jujuapi/api.go +++ b/internal/jujuapi/api.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. // Package jujuapi implements API endpoints for the juju API. package jujuapi diff --git a/internal/jujuapi/api_test.go b/internal/jujuapi/api_test.go index 360570503..c7054a927 100644 --- a/internal/jujuapi/api_test.go +++ b/internal/jujuapi/api_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi_test @@ -58,5 +58,7 @@ func (s *apiSuite) TestModelCommandsModelNotFoundf(c *gc.C) { if err != nil { c.Assert(err, gc.ErrorMatches, "websocket: bad handshake") } + defer response.Body.Close() + c.Assert(response.StatusCode, gc.Equals, http.StatusNotFound) } diff --git a/internal/jujuapi/applicationoffers.go b/internal/jujuapi/applicationoffers.go index bf3b70581..f7e3dc724 100644 --- a/internal/jujuapi/applicationoffers.go +++ b/internal/jujuapi/applicationoffers.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi diff --git a/internal/jujuapi/applicationoffers_test.go b/internal/jujuapi/applicationoffers_test.go index cc37619cd..c4a248694 100644 --- a/internal/jujuapi/applicationoffers_test.go +++ b/internal/jujuapi/applicationoffers_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi_test import ( @@ -365,8 +365,8 @@ func (s *applicationOffersSuite) TestDestroyOffers(c *gc.C) { // i need to fetch the offer so that i can manually set read // permission for charlie // - //err = client.GrantOffer("charlie@canonical.com", "read", offerURL) - //c.Assert(err, jc.ErrorIsNil) + // err = client.GrantOffer("charlie@canonical.com", "read", offerURL) + // c.Assert(err, jc.ErrorIsNil) offer := dbmodel.ApplicationOffer{ URL: offerURL, } diff --git a/internal/jujuapi/cloud.go b/internal/jujuapi/cloud.go index fe66d3dfd..1adb9b775 100644 --- a/internal/jujuapi/cloud.go +++ b/internal/jujuapi/cloud.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi @@ -481,8 +481,8 @@ func (r *controllerRoot) UpdateCloud(ctx context.Context, args jujuparams.Update results := jujuparams.ErrorResults{ Results: make([]jujuparams.ErrorResult, len(args.Clouds)), } - for i, arg := range args.Clouds { - err := r.updateCloud(ctx, arg) + for i := range args.Clouds { + err := r.updateCloud() if err != nil { results.Results[i].Error = mapError(err) } @@ -490,7 +490,7 @@ func (r *controllerRoot) UpdateCloud(ctx context.Context, args jujuparams.Update return results, nil } -func (r *controllerRoot) updateCloud(ctx context.Context, args jujuparams.AddCloudArgs) error { +func (r *controllerRoot) updateCloud() error { // TODO(mhilton) work out how to support updating clouds, for now // tell everyone they're not allowed. return errors.E(errors.CodeForbidden, "permission denied") diff --git a/internal/jujuapi/cloud_test.go b/internal/jujuapi/cloud_test.go index 669fb6f22..e2fa8a3b8 100644 --- a/internal/jujuapi/cloud_test.go +++ b/internal/jujuapi/cloud_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi_test @@ -729,6 +729,33 @@ func (s *cloudSuite) TestCredentialContents(c *gc.C) { }}) } +func (s *cloudSuite) TestCredentialContentsWithEmptyAttributes(c *gc.C) { + conn := s.open(c, nil, "test") + defer conn.Close() + client := cloudapi.NewClient(conn) + credentialTag := names.NewCloudCredentialTag(jimmtest.TestCloudName + "/test@canonical.com/cred3") + err := client.AddCredential( + credentialTag.String(), + cloud.NewCredential( + "certificate", + nil, + ), + ) + c.Assert(err, gc.Equals, nil) + creds, err := client.CredentialContents(jimmtest.TestCloudName, "cred3", false) + c.Assert(err, gc.Equals, nil) + c.Assert(creds, jc.DeepEquals, []jujuparams.CredentialContentResult{{ + Result: &jujuparams.ControllerCredentialInfo{ + Content: jujuparams.CredentialContent{ + Name: "cred3", + Cloud: jimmtest.TestCloudName, + AuthType: "certificate", + Attributes: nil, + }, + }, + }}) +} + func (s *cloudSuite) TestRemoveCloud(c *gc.C) { conn := s.open(c, nil, "test") defer conn.Close() diff --git a/internal/jujuapi/controller.go b/internal/jujuapi/controller.go index bc3795570..2549beaa5 100644 --- a/internal/jujuapi/controller.go +++ b/internal/jujuapi/controller.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi @@ -129,7 +129,7 @@ func (r *controllerRoot) WatchModelSummaries(ctx context.Context) (jujuparams.Su } return modelUUIDs, nil } - watcher, err := newModelSummaryWatcher(ctx, id, r, r.jimm.PubSubHub(), getModels) + watcher, err := newModelSummaryWatcher(ctx, id, r.jimm.PubSubHub(), getModels) if err != nil { return jujuparams.SummaryWatcherID{}, errors.E(op, err) } @@ -168,7 +168,7 @@ func (r *controllerRoot) WatchAllModelSummaries(ctx context.Context) (jujuparams return modelUUIDs, nil } - watcher, err := newModelSummaryWatcher(ctx, id, r, r.jimm.PubSubHub(), getAllModels) + watcher, err := newModelSummaryWatcher(ctx, id, r.jimm.PubSubHub(), getAllModels) if err != nil { return jujuparams.SummaryWatcherID{}, errors.E(op, err) } diff --git a/internal/jujuapi/controller_test.go b/internal/jujuapi/controller_test.go index c70073fc7..9e4cb63c7 100644 --- a/internal/jujuapi/controller_test.go +++ b/internal/jujuapi/controller_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi_test diff --git a/internal/jujuapi/controllerroot.go b/internal/jujuapi/controllerroot.go index a28b41345..82272e8cc 100644 --- a/internal/jujuapi/controllerroot.go +++ b/internal/jujuapi/controllerroot.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi @@ -24,7 +24,6 @@ import ( "github.com/canonical/jimm/v3/internal/openfga" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" "github.com/canonical/jimm/v3/internal/pubsub" - "github.com/canonical/jimm/v3/pkg/api/params" jimmnames "github.com/canonical/jimm/v3/pkg/names" ) @@ -32,26 +31,19 @@ type JIMM interface { GroupService RelationService ControllerService + LoginService + ModelManager AddAuditLogEntry(ale *dbmodel.AuditLogEntry) AddCloudToController(ctx context.Context, user *openfga.User, controllerName string, tag names.CloudTag, cloud jujuparams.Cloud, force bool) error AddHostedCloud(ctx context.Context, user *openfga.User, tag names.CloudTag, cloud jujuparams.Cloud, force bool) error - AddModel(ctx context.Context, u *openfga.User, args *jimm.ModelCreateArgs) (_ *jujuparams.ModelInfo, err error) AddServiceAccount(ctx context.Context, u *openfga.User, clientId string) error - OAuthAuthenticationService() jimm.OAuthAuthenticator - ChangeModelCredential(ctx context.Context, user *openfga.User, modelTag names.ModelTag, cloudCredentialTag names.CloudCredentialTag) error CopyServiceAccountCredential(ctx context.Context, u *openfga.User, svcAcc *openfga.User, cloudCredentialTag names.CloudCredentialTag) (names.CloudCredentialTag, []jujuparams.UpdateCredentialModelResult, error) - DestroyModel(ctx context.Context, u *openfga.User, mt names.ModelTag, destroyStorage *bool, force *bool, maxWait *time.Duration, timeout *time.Duration) error DestroyOffer(ctx context.Context, user *openfga.User, offerURL string, force bool) error - DumpModel(ctx context.Context, u *openfga.User, mt names.ModelTag, simplified bool) (string, error) - DumpModelDB(ctx context.Context, u *openfga.User, mt names.ModelTag) (map[string]interface{}, error) FindApplicationOffers(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) FindAuditEvents(ctx context.Context, user *openfga.User, filter db.AuditLogFilter) ([]dbmodel.AuditLogEntry, error) ForEachCloud(ctx context.Context, user *openfga.User, f func(*dbmodel.Cloud) error) error - ForEachModel(ctx context.Context, u *openfga.User, f func(*dbmodel.Model, jujuparams.UserAccessPermission) error) error ForEachUserCloud(ctx context.Context, user *openfga.User, f func(*dbmodel.Cloud) error) error ForEachUserCloudCredential(ctx context.Context, u *dbmodel.Identity, ct names.CloudTag, f func(cred *dbmodel.CloudCredential) error) error - ForEachUserModel(ctx context.Context, u *openfga.User, f func(*dbmodel.Model, jujuparams.UserAccessPermission) error) error - FullModelStatus(ctx context.Context, user *openfga.User, modelTag names.ModelTag, patterns []string) (*jujuparams.FullStatus, error) GetApplicationOffer(ctx context.Context, user *openfga.User, offerURL string) (*jujuparams.ApplicationOfferAdminDetailsV5, error) GetApplicationOfferConsumeDetails(ctx context.Context, user *openfga.User, details *jujuparams.ConsumeOfferDetails, v bakery.Version) error GetCloud(ctx context.Context, u *openfga.User, tag names.CloudTag) (dbmodel.Cloud, error) @@ -59,8 +51,6 @@ type JIMM interface { GetCloudCredentialAttributes(ctx context.Context, u *openfga.User, cred *dbmodel.CloudCredential, hidden bool) (attrs map[string]string, redacted []string, err error) GetCredentialStore() credentials.CredentialStore GetJimmControllerAccess(ctx context.Context, user *openfga.User, tag names.UserTag) (string, error) - // GetUser finds or creates the user in jimm - GetUser(ctx context.Context, username string) (*openfga.User, error) ListIdentities(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]openfga.User, error) // FetchIdentity finds the user in jimm or returns a not-found error FetchIdentity(ctx context.Context, username string) (*openfga.User, error) @@ -73,18 +63,12 @@ type JIMM interface { GrantModelAccess(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error GrantOfferAccess(ctx context.Context, u *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) error GrantServiceAccountAccess(ctx context.Context, u *openfga.User, svcAccTag jimmnames.ServiceAccountTag, tags []string) error - IdentityModelDefaults(ctx context.Context, user *dbmodel.Identity) (map[string]interface{}, error) - ImportModel(ctx context.Context, user *openfga.User, controllerName string, modelTag names.ModelTag, newOwner string) error InitiateInternalMigration(ctx context.Context, user *openfga.User, modelTag names.ModelTag, targetController string) (jujuparams.InitiateMigrationResult, error) InitiateMigration(ctx context.Context, user *openfga.User, spec jujuparams.MigrationSpec) (jujuparams.InitiateMigrationResult, error) ListApplicationOffers(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) - ModelDefaultsForCloud(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag) (jujuparams.ModelDefaultsResult, error) - ModelInfo(ctx context.Context, u *openfga.User, mt names.ModelTag) (*jujuparams.ModelInfo, error) - ModelStatus(ctx context.Context, u *openfga.User, mt names.ModelTag) (*jujuparams.ModelStatus, error) Offer(ctx context.Context, user *openfga.User, offer jimm.AddApplicationOfferParams) error PubSubHub() *pubsub.Hub PurgeLogs(ctx context.Context, user *openfga.User, before time.Time) (int64, error) - QueryModelsJq(ctx context.Context, models []string, jqQuery string) (params.CrossModelQueryResponse, error) RemoveCloud(ctx context.Context, u *openfga.User, ct names.CloudTag) error RemoveCloudFromController(ctx context.Context, u *openfga.User, controllerName string, ct names.CloudTag) error RemoveController(ctx context.Context, user *openfga.User, controllerName string, force bool) error @@ -94,16 +78,11 @@ type JIMM interface { RevokeCloudCredential(ctx context.Context, user *dbmodel.Identity, tag names.CloudCredentialTag, force bool) error RevokeModelAccess(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error RevokeOfferAccess(ctx context.Context, user *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) (err error) - SetModelDefaults(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag, region string, configs map[string]interface{}) error ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs bool) (string, error) - UnsetModelDefaults(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag, region string, keys []string) error UpdateApplicationOffer(ctx context.Context, controller *dbmodel.Controller, offerUUID string, removed bool) error UpdateCloud(ctx context.Context, u *openfga.User, ct names.CloudTag, cloud jujuparams.Cloud) error UpdateCloudCredential(ctx context.Context, u *openfga.User, args jimm.UpdateCloudCredentialArgs) ([]jujuparams.UpdateCredentialModelResult, error) - UpdateMigratedModel(ctx context.Context, user *openfga.User, modelTag names.ModelTag, targetControllerName string) error - UpdateUserLastLogin(ctx context.Context, identifier string) error - ValidateModelUpgrade(ctx context.Context, u *openfga.User, mt names.ModelTag, force bool) error - WatchAllModelSummaries(ctx context.Context, controller *dbmodel.Controller) (_ func() error, err error) + UserLogin(ctx context.Context, identityName string) (*openfga.User, error) } // controllerRoot is the root for endpoints served on controller connections. @@ -177,7 +156,7 @@ func (r *controllerRoot) masquerade(ctx context.Context, userTag string) (*openf if !r.user.JimmAdmin { return nil, errors.E(errors.CodeUnauthorized, "unauthorized") } - user, err := r.jimm.GetUser(ctx, ut.Id()) + user, err := r.jimm.UserLogin(ctx, ut.Id()) if err != nil { return nil, err } diff --git a/internal/jujuapi/controllerroot_test.go b/internal/jujuapi/controllerroot_test.go index 362f05ab6..25939920d 100644 --- a/internal/jujuapi/controllerroot_test.go +++ b/internal/jujuapi/controllerroot_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi_test diff --git a/internal/jujuapi/export_test.go b/internal/jujuapi/export_test.go index 0fa2eeb82..5d0d6d197 100644 --- a/internal/jujuapi/export_test.go +++ b/internal/jujuapi/export_test.go @@ -1,15 +1,16 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi import ( "context" + jujuparams "github.com/juju/juju/rpc/params" + "github.com/canonical/jimm/v3/internal/db" "github.com/canonical/jimm/v3/internal/jimm" "github.com/canonical/jimm/v3/internal/openfga" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" - jujuparams "github.com/juju/juju/rpc/params" ) var ( diff --git a/internal/jujuapi/jimm.go b/internal/jujuapi/jimm.go index 009b3bdce..39768247d 100644 --- a/internal/jujuapi/jimm.go +++ b/internal/jujuapi/jimm.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi @@ -176,15 +176,6 @@ func (r *controllerRoot) AddController(ctx context.Context, req apiparams.AddCon } } - ctl := dbmodel.Controller{ - UUID: req.UUID, - Name: req.Name, - PublicAddress: req.PublicAddress, - CACertificate: req.CACertificate, - AdminIdentityName: req.Username, - AdminPassword: req.Password, - TLSHostname: req.TLSHostname, - } nphps, err := network.ParseProviderHostPorts(req.APIAddresses...) if err != nil { return apiparams.ControllerInfo{}, errors.E(op, errors.CodeBadRequest, err) @@ -195,7 +186,18 @@ func (r *controllerRoot) AddController(ctx context.Context, req apiparams.AddCon nphps[i].Scope = network.ScopePublic } } - ctl.Addresses = dbmodel.HostPorts{jujuparams.FromProviderHostPorts(nphps)} + + // TODO(ale8k): Don't build dbmodel here, do it as params to AddController. + ctl := dbmodel.Controller{ + UUID: req.UUID, + Name: req.Name, + PublicAddress: req.PublicAddress, + CACertificate: req.CACertificate, + AdminIdentityName: req.Username, + AdminPassword: req.Password, + TLSHostname: req.TLSHostname, + Addresses: dbmodel.HostPorts{jujuparams.FromProviderHostPorts(nphps)}, + } if err := r.jimm.AddController(ctx, r.user, &ctl); err != nil { zapctx.Error(ctx, "failed to add controller", zaputil.Error(err)) return apiparams.ControllerInfo{}, errors.E(op, err) diff --git a/internal/jujuapi/jimm_relation.go b/internal/jujuapi/jimm_relation.go index ad17d02a8..2e54225d6 100644 --- a/internal/jujuapi/jimm_relation.go +++ b/internal/jujuapi/jimm_relation.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi diff --git a/internal/jujuapi/jimm_test.go b/internal/jujuapi/jimm_test.go index 8c627a8fe..140c97705 100644 --- a/internal/jujuapi/jimm_test.go +++ b/internal/jujuapi/jimm_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi_test diff --git a/internal/jujuapi/modelmanager.go b/internal/jujuapi/modelmanager.go index 0736cdf2e..cc0e67f82 100644 --- a/internal/jujuapi/modelmanager.go +++ b/internal/jujuapi/modelmanager.go @@ -1,10 +1,11 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi import ( "context" "fmt" + "time" jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" @@ -13,7 +14,9 @@ import ( "github.com/canonical/jimm/v3/internal/errors" "github.com/canonical/jimm/v3/internal/jimm" "github.com/canonical/jimm/v3/internal/jujuapi/rpc" + "github.com/canonical/jimm/v3/internal/openfga" "github.com/canonical/jimm/v3/internal/servermon" + "github.com/canonical/jimm/v3/pkg/api/params" ) func init() { @@ -52,6 +55,29 @@ func init() { } } +// ModelManager defines the model related operations that JIMM can perform. +type ModelManager interface { + AddModel(ctx context.Context, u *openfga.User, args *jimm.ModelCreateArgs) (_ *jujuparams.ModelInfo, err error) + ChangeModelCredential(ctx context.Context, user *openfga.User, modelTag names.ModelTag, cloudCredentialTag names.CloudCredentialTag) error + DestroyModel(ctx context.Context, u *openfga.User, mt names.ModelTag, destroyStorage *bool, force *bool, maxWait *time.Duration, timeout *time.Duration) error + DumpModel(ctx context.Context, u *openfga.User, mt names.ModelTag, simplified bool) (string, error) + DumpModelDB(ctx context.Context, u *openfga.User, mt names.ModelTag) (map[string]interface{}, error) + ForEachModel(ctx context.Context, u *openfga.User, f func(*dbmodel.Model, jujuparams.UserAccessPermission) error) error + ForEachUserModel(ctx context.Context, u *openfga.User, f func(*dbmodel.Model, jujuparams.UserAccessPermission) error) error + FullModelStatus(ctx context.Context, user *openfga.User, modelTag names.ModelTag, patterns []string) (*jujuparams.FullStatus, error) + IdentityModelDefaults(ctx context.Context, user *dbmodel.Identity) (map[string]interface{}, error) + ImportModel(ctx context.Context, user *openfga.User, controllerName string, modelTag names.ModelTag, newOwner string) error + ModelDefaultsForCloud(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag) (jujuparams.ModelDefaultsResult, error) + ModelInfo(ctx context.Context, u *openfga.User, mt names.ModelTag) (*jujuparams.ModelInfo, error) + ModelStatus(ctx context.Context, u *openfga.User, mt names.ModelTag) (*jujuparams.ModelStatus, error) + QueryModelsJq(ctx context.Context, models []string, jqQuery string) (params.CrossModelQueryResponse, error) + SetModelDefaults(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag, region string, configs map[string]interface{}) error + UnsetModelDefaults(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag, region string, keys []string) error + UpdateMigratedModel(ctx context.Context, user *openfga.User, modelTag names.ModelTag, targetControllerName string) error + ValidateModelUpgrade(ctx context.Context, u *openfga.User, mt names.ModelTag, force bool) error + WatchAllModelSummaries(ctx context.Context, controller *dbmodel.Controller) (_ func() error, err error) +} + // DumpModels implements the DumpModels method of the modelmanager (version // 3 onwards) facade. The model dump is passed back as-is from the // controller without any changes from JIMM. @@ -130,10 +156,8 @@ func (r *controllerRoot) ModelInfo(ctx context.Context, args jujuparams.Entities err = errors.E(op, errors.CodeUnauthorized, "unauthorized") } results[i].Error = mapError(errors.E(op, err)) - } else { - if r.controllerUUIDMasking { - results[i].Result.ControllerUUID = r.params.ControllerUUID - } + } else if r.controllerUUIDMasking { + results[i].Result.ControllerUUID = r.params.ControllerUUID } } return jujuparams.ModelInfoResults{ diff --git a/internal/jujuapi/modelmanager_test.go b/internal/jujuapi/modelmanager_test.go index b4affbc19..31162d17e 100644 --- a/internal/jujuapi/modelmanager_test.go +++ b/internal/jujuapi/modelmanager_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi_test @@ -258,11 +258,11 @@ func (s *modelManagerSuite) TestModelInfo(c *gc.C) { mt4 := s.AddModel(c, names.NewUserTag("charlie@canonical.com"), "model-4", names.NewCloudTag(jimmtest.TestCloudName), jimmtest.TestCloudRegionName, s.Model2.CloudCredential.ResourceTag()) // TODO (alesstimec) change once granting has been re-implemented - //conn := s.open(c, nil, "charlie") - //defer conn.Close() - //client := modelmanager.NewClient(conn) - //err := client.GrantModel("bob@canonical.com", "write", mt4.Id()) - //c.Assert(err, gc.Equals, nil) + // conn := s.open(c, nil, "charlie") + // defer conn.Close() + // client := modelmanager.NewClient(conn) + // err := client.GrantModel("bob@canonical.com", "write", mt4.Id()) + // c.Assert(err, gc.Equals, nil) bobIdentity, err := dbmodel.NewIdentity("bob@canonical.com") c.Assert(err, gc.IsNil) @@ -272,8 +272,8 @@ func (s *modelManagerSuite) TestModelInfo(c *gc.C) { mt5 := s.AddModel(c, names.NewUserTag("charlie@canonical.com"), "model-5", names.NewCloudTag(jimmtest.TestCloudName), jimmtest.TestCloudRegionName, s.Model2.CloudCredential.ResourceTag()) // TODO (alesstimec) change once granting has been re-implemented - //err = client.GrantModel("bob@canonical.com", "admin", mt5.Id()) - //c.Assert(err, gc.Equals, nil) + // err = client.GrantModel("bob@canonical.com", "admin", mt5.Id()) + // c.Assert(err, gc.Equals, nil) err = bob.SetModelAccess(context.Background(), mt5, ofganames.AdministratorRelation) c.Assert(err, gc.Equals, nil) @@ -860,19 +860,17 @@ var createModelTests = []struct { cloudTag: "not-a-cloud-tag", credentialTag: "cloudcred-" + jimmtest.TestCloudName + "_bob@canonical.com_cred1", expectError: `"not-a-cloud-tag" is not a valid tag \(bad request\)`, -}, { - about: "no cloud tag", - name: "model-8", - ownerTag: names.NewUserTag("bob@canonical.com").String(), - cloudTag: "", - credentialTag: "cloudcred-" + jimmtest.TestCloudName + "_bob@canonical.com_cred1", - expectError: `no cloud specified for model; please specify one`, }, { about: "no credential tag selects unambigous creds", name: "model-8", ownerTag: names.NewUserTag("bob@canonical.com").String(), cloudTag: names.NewCloudTag(jimmtest.TestCloudName).String(), region: jimmtest.TestCloudRegionName, +}, { + about: "success - without a cloud tag", + name: "model-9", + ownerTag: names.NewUserTag("bob@canonical.com").String(), + credentialTag: "cloudcred-" + jimmtest.TestCloudName + "_bob@canonical.com_cred", }} func (s *modelManagerSuite) TestCreateModel(c *gc.C) { diff --git a/internal/jujuapi/modelsummarywatcher.go b/internal/jujuapi/modelsummarywatcher.go index 17e24fbb9..0e1995574 100644 --- a/internal/jujuapi/modelsummarywatcher.go +++ b/internal/jujuapi/modelsummarywatcher.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi @@ -100,7 +100,7 @@ func (r *watcherRegistry) get(id string) (*modelSummaryWatcher, error) { return w, nil } -func newModelSummaryWatcher(ctx context.Context, id string, root *controllerRoot, pubsub *pubsub.Hub, modelGetterFunc func(context.Context) ([]string, error)) (*modelSummaryWatcher, error) { +func newModelSummaryWatcher(ctx context.Context, id string, pubsub *pubsub.Hub, modelGetterFunc func(context.Context) ([]string, error)) (*modelSummaryWatcher, error) { const op = errors.Op("jujuapi.newModelSummaryWatcher") ctx, cancelContext := context.WithCancel(ctx) @@ -186,6 +186,7 @@ func (w *modelSummaryWatcher) Stop() error { return nil } +//nolint:unused // Used in export-test. func newModelAccessWatcher(ctx context.Context, period time.Duration, modelGetterFunc func(context.Context) ([]string, error)) *modelAccessWatcher { return &modelAccessWatcher{ ctx: ctx, diff --git a/internal/jujuapi/modelsummarywatcher_test.go b/internal/jujuapi/modelsummarywatcher_test.go index 80c4496bb..4a2302057 100644 --- a/internal/jujuapi/modelsummarywatcher_test.go +++ b/internal/jujuapi/modelsummarywatcher_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi_test @@ -20,8 +20,10 @@ var _ = gc.Suite(&modelSummaryWatcherSuite{}) func (s *modelSummaryWatcherSuite) TestModelSummaryWatcher(c *gc.C) { watcher := jujuapi.NewModelSummaryWatcher() - defer watcher.Stop() - + defer func() { + err := watcher.Stop() + c.Assert(err, gc.IsNil) + }() result, err := watcher.Next() c.Assert(err, jc.ErrorIsNil) c.Assert(result, gc.DeepEquals, jujuparams.SummaryWatcherNextResults{ diff --git a/internal/jujuapi/package_test.go b/internal/jujuapi/package_test.go index e9f8e3562..afbc7e686 100644 --- a/internal/jujuapi/package_test.go +++ b/internal/jujuapi/package_test.go @@ -1,4 +1,4 @@ -// Copyright 2015 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi_test @@ -8,6 +8,7 @@ import ( jujutesting "github.com/juju/juju/testing" ) +// Registers Go Check tests into the Go test runner. func TestPackage(t *testing.T) { jujutesting.MgoTestPackage(t) } diff --git a/internal/jujuapi/pinger.go b/internal/jujuapi/pinger.go index cdd90191c..f191ed3fc 100644 --- a/internal/jujuapi/pinger.go +++ b/internal/jujuapi/pinger.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi diff --git a/internal/jujuapi/pinger_internal_test.go b/internal/jujuapi/pinger_internal_test.go index 8fbe84ece..e05efb831 100644 --- a/internal/jujuapi/pinger_internal_test.go +++ b/internal/jujuapi/pinger_internal_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi diff --git a/internal/jujuapi/rpc/method.go b/internal/jujuapi/rpc/method.go index 7e8f67ac8..08396493b 100644 --- a/internal/jujuapi/rpc/method.go +++ b/internal/jujuapi/rpc/method.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package rpc @@ -20,7 +20,7 @@ var ( // Method converts the given function to an RPC method that can be used // with Root. The function must have a signature like: // -// f([ctx context.Context, ][objId string, ][params ParamsT]) ([ResultT, ][error]) +// f([ctx context.Context, ][objId string, ][params ParamsT]) ([ResultT, ][error]) // // Note that all parameters and return values are optional. Method will // panic if the given value is not a function of the correct type. @@ -101,10 +101,8 @@ func (c methodCaller) Call(ctx context.Context, objId string, arg reflect.Value) } if c.flags&inObjectID == inObjectID { pv = append(pv, reflect.ValueOf(objId)) - } else { - if objId != "" { - return reflect.Value{}, errors.ErrBadId - } + } else if objId != "" { + return reflect.Value{}, errors.ErrBadId } if c.flags&inParams == inParams { pv = append(pv, arg) diff --git a/internal/jujuapi/rpc/method_test.go b/internal/jujuapi/rpc/method_test.go index caa7f8a52..14129fb7a 100644 --- a/internal/jujuapi/rpc/method_test.go +++ b/internal/jujuapi/rpc/method_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package rpc_test diff --git a/internal/jujuapi/rpc/root.go b/internal/jujuapi/rpc/root.go index 91d78b42c..af1aa0014 100644 --- a/internal/jujuapi/rpc/root.go +++ b/internal/jujuapi/rpc/root.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package rpc diff --git a/internal/jujuapi/rpc/root_test.go b/internal/jujuapi/rpc/root_test.go index 3f74e275d..3482f9f3d 100644 --- a/internal/jujuapi/rpc/root_test.go +++ b/internal/jujuapi/rpc/root_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package rpc_test diff --git a/internal/jujuapi/service_account.go b/internal/jujuapi/service_account.go index b1ce10343..2e0b5dc85 100644 --- a/internal/jujuapi/service_account.go +++ b/internal/jujuapi/service_account.go @@ -1,4 +1,4 @@ -// Copyright 2024 canonical. +// Copyright 2024 Canonical. package jujuapi @@ -72,7 +72,7 @@ func (r *controllerRoot) getServiceAccount(ctx context.Context, clientID string) return nil, errors.E(errors.CodeUnauthorized, "unauthorized") } - return r.jimm.GetUser(ctx, clientIdWithDomain) + return r.jimm.UserLogin(ctx, clientIdWithDomain) } // UpdateServiceAccountCredentialsCheckModels updates a set of cloud credentials' content. diff --git a/internal/jujuapi/service_account_test.go b/internal/jujuapi/service_account_test.go index 71de706e3..b6d14a222 100644 --- a/internal/jujuapi/service_account_test.go +++ b/internal/jujuapi/service_account_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi_test @@ -175,7 +175,7 @@ func TestCopyServiceAccountCredential(t *testing.T) { newCredTag := names.NewCloudCredentialTag(fmt.Sprintf("%s/%s/%s", test.args.CloudName, svcAcc.Name, test.args.CredentialName)) return newCredTag, nil, nil }, - GetUser_: func(ctx context.Context, email string) (*openfga.User, error) { + UserLogin_: func(ctx context.Context, email string) (*openfga.User, error) { var u dbmodel.Identity u.SetTag(names.NewUserTag(email)) return openfga.NewUser(&u, ofgaClient), nil @@ -187,7 +187,8 @@ func TestCopyServiceAccountCredential(t *testing.T) { cr := jujuapi.NewControllerRoot(jimm, jujuapi.Params{}) jujuapi.SetUser(cr, user) if len(test.addTuples) > 0 { - ofgaClient.AddRelation(context.Background(), test.addTuples...) + err = ofgaClient.AddRelation(context.Background(), test.addTuples...) + c.Assert(err, qt.IsNil) } res, err := cr.CopyServiceAccountCredential(context.Background(), test.args) if test.expectedError == "" { @@ -258,7 +259,7 @@ func TestGetServiceAccount(t *testing.T) { err = pgDb.Migrate(context.Background(), false) c.Assert(err, qt.IsNil) jimm := &jimmtest.JIMM{ - GetUser_: func(ctx context.Context, email string) (*openfga.User, error) { + UserLogin_: func(ctx context.Context, email string) (*openfga.User, error) { var u dbmodel.Identity u.SetTag(names.NewUserTag(email)) return openfga.NewUser(&u, ofgaClient), nil @@ -271,7 +272,8 @@ func TestGetServiceAccount(t *testing.T) { jujuapi.SetUser(cr, user) if len(test.addTuples) > 0 { - ofgaClient.AddRelation(context.Background(), test.addTuples...) + err = ofgaClient.AddRelation(context.Background(), test.addTuples...) + c.Assert(err, qt.IsNil) } res, err := cr.GetServiceAccount(context.Background(), test.clientID) @@ -451,7 +453,7 @@ func TestUpdateServiceAccountCredentials(t *testing.T) { c.Assert(err, qt.IsNil) jimm := &jimmtest.JIMM{ UpdateCloudCredential_: test.updateCloudCredential, - GetUser_: func(ctx context.Context, email string) (*openfga.User, error) { return nil, nil }, + UserLogin_: func(ctx context.Context, email string) (*openfga.User, error) { return nil, nil }, } var u dbmodel.Identity u.SetTag(names.NewUserTag(test.username)) @@ -460,7 +462,8 @@ func TestUpdateServiceAccountCredentials(t *testing.T) { jujuapi.SetUser(cr, user) if len(test.addTuples) > 0 { - ofgaClient.AddRelation(context.Background(), test.addTuples...) + err = ofgaClient.AddRelation(context.Background(), test.addTuples...) + c.Assert(err, qt.IsNil) } res, err := cr.UpdateServiceAccountCredentials(context.Background(), test.args) @@ -583,7 +586,7 @@ func TestListServiceAccountCredentials(t *testing.T) { GetCloudCredential_: test.getCloudCredential, GetCloudCredentialAttributes_: test.getCloudCredentialAttributes, ForEachUserCloudCredential_: test.ForEachUserCloudCredential, - GetUser_: func(ctx context.Context, email string) (*openfga.User, error) { + UserLogin_: func(ctx context.Context, email string) (*openfga.User, error) { var u dbmodel.Identity u.SetTag(names.NewUserTag(email)) return openfga.NewUser(&u, ofgaClient), nil @@ -596,7 +599,8 @@ func TestListServiceAccountCredentials(t *testing.T) { jujuapi.SetUser(cr, user) if len(test.addTuples) > 0 { - ofgaClient.AddRelation(context.Background(), test.addTuples...) + err = ofgaClient.AddRelation(context.Background(), test.addTuples...) + c.Assert(err, qt.IsNil) } res, err := cr.ListServiceAccountCredentials(context.Background(), test.args) @@ -698,7 +702,7 @@ func TestGrantServiceAccountAccess(t *testing.T) { err = pgDb.Migrate(context.Background(), false) c.Assert(err, qt.IsNil) jimm := &jimmtest.JIMM{ - GetUser_: func(ctx context.Context, email string) (*openfga.User, error) { return nil, nil }, + UserLogin_: func(ctx context.Context, email string) (*openfga.User, error) { return nil, nil }, GrantServiceAccountAccess_: test.grantServiceAccountAccess, } var u dbmodel.Identity @@ -708,7 +712,8 @@ func TestGrantServiceAccountAccess(t *testing.T) { jujuapi.SetUser(cr, user) if len(test.addTuples) > 0 { - ofgaClient.AddRelation(context.Background(), test.addTuples...) + err = ofgaClient.AddRelation(context.Background(), test.addTuples...) + c.Assert(err, qt.IsNil) } err = cr.GrantServiceAccountAccess(context.Background(), test.params) @@ -740,14 +745,16 @@ func (s *serviceAccountSuite) TestUpdateServiceAccountCredentialsIntegration(c * Target: ofganames.ConvertTag(serviceAccount), } - s.JIMM.OpenFGAClient.AddRelation(context.Background(), tuple) + err := s.JIMM.OpenFGAClient.AddRelation(context.Background(), tuple) + c.Assert(err, gc.IsNil) cloud := &dbmodel.Cloud{ Name: "aws", } - s.JIMM.Database.AddCloud(context.Background(), cloud) + err = s.JIMM.Database.AddCloud(context.Background(), cloud) + c.Assert(err, gc.IsNil) var credResults jujuparams.UpdateCredentialResults - err := conn.APICall("JIMM", 4, "", "UpdateServiceAccountCredentials", params.UpdateServiceAccountCredentialsRequest{ + err = conn.APICall("JIMM", 4, "", "UpdateServiceAccountCredentials", params.UpdateServiceAccountCredentialsRequest{ ClientID: "fca1f605-736e-4d1f-bcd2-aecc726923be@serviceaccount", UpdateCredentialArgs: jujuparams.UpdateCredentialArgs{ Credentials: []jujuparams.TaggedCredential{ diff --git a/internal/jujuapi/usermanager.go b/internal/jujuapi/usermanager.go index 965c6a14b..766af8eb4 100644 --- a/internal/jujuapi/usermanager.go +++ b/internal/jujuapi/usermanager.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi @@ -66,7 +66,7 @@ func (r *controllerRoot) UserInfo(ctx context.Context, req jujuparams.UserInfoRe Results: make([]jujuparams.UserInfoResult, len(req.Entities)), } for i, ent := range req.Entities { - ui, err := r.userInfo(ctx, ent.Tag) + ui, err := r.userInfo(ent.Tag) if err != nil { res.Results[i].Error = mapError(err) continue @@ -76,7 +76,7 @@ func (r *controllerRoot) UserInfo(ctx context.Context, req jujuparams.UserInfoRe return res, nil } -func (r *controllerRoot) userInfo(ctx context.Context, entity string) (*jujuparams.UserInfo, error) { +func (r *controllerRoot) userInfo(entity string) (*jujuparams.UserInfo, error) { const op = errors.Op("jujuapi.UserInfo") user, err := parseUserTag(entity) diff --git a/internal/jujuapi/usermanager_test.go b/internal/jujuapi/usermanager_test.go index 54fd5aeeb..2f1550252 100644 --- a/internal/jujuapi/usermanager_test.go +++ b/internal/jujuapi/usermanager_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi_test diff --git a/internal/jujuapi/websocket.go b/internal/jujuapi/websocket.go index c68db2614..df7c73318 100644 --- a/internal/jujuapi/websocket.go +++ b/internal/jujuapi/websocket.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi @@ -151,10 +151,13 @@ func (s modelProxyServer) ServeWS(ctx context.Context, clientConn *websocket.Con TokenGen: &jwtGenerator, ConnectController: connectionFunc, AuditLog: auditLogger, - JIMM: s.jimm, + LoginService: s.jimm, AuthenticatedIdentityID: auth.SessionIdentityFromContext(ctx), } - jimmRPC.ProxySockets(ctx, proxyHelpers) + if err := jimmRPC.ProxySockets(ctx, proxyHelpers); err != nil { + zapctx.Error(ctx, "failed to start jimm model proxy", zap.Error(err)) + } + } // controllerConnectionFunc returns a function that will be used to diff --git a/internal/jujuapi/websocket_test.go b/internal/jujuapi/websocket_test.go index 2711f92b1..88adc7b7d 100644 --- a/internal/jujuapi/websocket_test.go +++ b/internal/jujuapi/websocket_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. package jujuapi_test diff --git a/internal/jujuclient/allwatcher.go b/internal/jujuclient/allwatcher.go index 39a02e0ef..b1ecc55db 100644 --- a/internal/jujuclient/allwatcher.go +++ b/internal/jujuclient/allwatcher.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient diff --git a/internal/jujuclient/allwatcher_test.go b/internal/jujuclient/allwatcher_test.go index 4da4099a3..b04b28d5b 100644 --- a/internal/jujuclient/allwatcher_test.go +++ b/internal/jujuclient/allwatcher_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient_test diff --git a/internal/jujuclient/applicationoffers.go b/internal/jujuclient/applicationoffers.go index 9044f4c58..94e2e7b26 100644 --- a/internal/jujuclient/applicationoffers.go +++ b/internal/jujuclient/applicationoffers.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient @@ -209,13 +209,15 @@ func (c Connection) GetApplicationOfferConsumeDetails(ctx context.Context, user OfferURLs: []string{info.Offer.OfferURL}, BakeryVersion: v, }, - UserTag: user.String(), + // Do not include a user in the args, Juju will opt to use the user authenticated in the connection. + // There is a bug where setting the user tag does not behave as expected. + UserTag: "", } resp := jujuparams.ConsumeOfferDetailsResults{ Results: make([]jujuparams.ConsumeOfferDetailsResult, 1), } - err := c.CallHighestFacadeVersion(ctx, "ApplicationOffers", []int{4, 3}, "", "GetConsumeDetails", &args, &resp) + err := c.CallHighestFacadeVersion(ctx, "ApplicationOffers", []int{5, 4}, "", "GetConsumeDetails", &args, &resp) if err != nil { return errors.E(op, jujuerrors.Cause(err)) } diff --git a/internal/jujuclient/applicationoffers_test.go b/internal/jujuclient/applicationoffers_test.go index 5703fb7a4..4cf05d295 100644 --- a/internal/jujuclient/applicationoffers_test.go +++ b/internal/jujuclient/applicationoffers_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient_test diff --git a/internal/jujuclient/client.go b/internal/jujuclient/client.go index 880ed24c4..2250473ab 100644 --- a/internal/jujuclient/client.go +++ b/internal/jujuclient/client.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient diff --git a/internal/jujuclient/client_test.go b/internal/jujuclient/client_test.go index 63d10377d..35ebba3a6 100644 --- a/internal/jujuclient/client_test.go +++ b/internal/jujuclient/client_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient_test import ( diff --git a/internal/jujuclient/cloud.go b/internal/jujuclient/cloud.go index 6f67fd43e..16916eb0f 100644 --- a/internal/jujuclient/cloud.go +++ b/internal/jujuclient/cloud.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient @@ -147,7 +147,7 @@ func (c Connection) Cloud(ctx context.Context, tag names.CloudTag, cloud *jujupa return errors.E(op, jujuerrors.Cause(err)) } if resp.Results[0].Error != nil { - errors.E(op, resp.Results[0].Error) + return errors.E(op, resp.Results[0].Error) } return nil } diff --git a/internal/jujuclient/cloud_test.go b/internal/jujuclient/cloud_test.go index 82debae1b..6cb4a4980 100644 --- a/internal/jujuclient/cloud_test.go +++ b/internal/jujuclient/cloud_test.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package jujuclient_test import ( @@ -216,7 +217,7 @@ func (s *cloudSuite) TestClouds(c *gc.C) { clouds, err := s.API.Clouds(context.Background()) c.Assert(err, gc.Equals, nil) c.Assert(clouds, jc.DeepEquals, map[names.CloudTag]jujuparams.Cloud{ - names.NewCloudTag(jimmtest.TestCloudName): jujuparams.Cloud{ + names.NewCloudTag(jimmtest.TestCloudName): { Type: jimmtest.TestProviderType, AuthTypes: []string{"empty", "userpass"}, Endpoint: jimmtest.TestCloudEndpoint, diff --git a/internal/jujuclient/dial.go b/internal/jujuclient/dial.go index 3d08a0a9b..de3ab4994 100644 --- a/internal/jujuclient/dial.go +++ b/internal/jujuclient/dial.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. // Package jujuclient is the client JIMM uses to connect to juju // controllers. The jujuclient uses the juju RPC API directly using @@ -129,6 +129,7 @@ func (d *Dialer) Dial(ctx context.Context, ctl *dbmodel.Controller, modelTag nam dialer: d, ctl: ctl, mt: modelTag, + redialCount: new(atomic.Int32), }, nil } @@ -184,7 +185,7 @@ type Connection struct { broken *uint32 dialer *Dialer - redialCount atomic.Int32 + redialCount *atomic.Int32 ctl *dbmodel.Controller mt names.ModelTag } @@ -215,6 +216,7 @@ func (c *Connection) hasFacadeVersion(facade string, version int) bool { func (c *Connection) redial(ctx context.Context, requiredPermissions map[string]string) error { const op = errors.Op("jujuclient.redial") + dialCount := c.redialCount.Add(1) if dialCount > 10 { return errors.E(op, "dial count exceeded") diff --git a/internal/jujuclient/dial_test.go b/internal/jujuclient/dial_test.go index af5708c50..4e3621b41 100644 --- a/internal/jujuclient/dial_test.go +++ b/internal/jujuclient/dial_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient_test @@ -90,14 +90,6 @@ func (s *dialSuite) TestDial(c *gc.C) { c.Check(addrs, jc.DeepEquals, info.Addrs) } -type cExtended struct { - *gc.C -} - -func (t *cExtended) Name() string { - return t.TestName() -} - func (s *dialSuite) TestDialWithJWT(c *gc.C) { ctx := context.Background() diff --git a/internal/jujuclient/modelmanager.go b/internal/jujuclient/modelmanager.go index b49d1b447..0fcf3160f 100644 --- a/internal/jujuclient/modelmanager.go +++ b/internal/jujuclient/modelmanager.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient diff --git a/internal/jujuclient/modelmanager_test.go b/internal/jujuclient/modelmanager_test.go index 581209920..3d6978e02 100644 --- a/internal/jujuclient/modelmanager_test.go +++ b/internal/jujuclient/modelmanager_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient_test diff --git a/internal/jujuclient/modelsummarywatcher.go b/internal/jujuclient/modelsummarywatcher.go index 21dbb3daf..4e494369d 100644 --- a/internal/jujuclient/modelsummarywatcher.go +++ b/internal/jujuclient/modelsummarywatcher.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient diff --git a/internal/jujuclient/modelsummarywatcher_test.go b/internal/jujuclient/modelsummarywatcher_test.go index 7472c39f3..a8755e555 100644 --- a/internal/jujuclient/modelsummarywatcher_test.go +++ b/internal/jujuclient/modelsummarywatcher_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient_test diff --git a/internal/jujuclient/modelwatcher.go b/internal/jujuclient/modelwatcher.go index 7d3ae847f..00137b572 100644 --- a/internal/jujuclient/modelwatcher.go +++ b/internal/jujuclient/modelwatcher.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient diff --git a/internal/jujuclient/modelwatcher_test.go b/internal/jujuclient/modelwatcher_test.go index 4cd1076d2..eac3db6bc 100644 --- a/internal/jujuclient/modelwatcher_test.go +++ b/internal/jujuclient/modelwatcher_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient_test diff --git a/internal/jujuclient/package_test.go b/internal/jujuclient/package_test.go index bbfecbcbf..9d72247eb 100644 --- a/internal/jujuclient/package_test.go +++ b/internal/jujuclient/package_test.go @@ -1,4 +1,4 @@ -// Copyright 2015 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient_test diff --git a/internal/jujuclient/ping.go b/internal/jujuclient/ping.go index b91a4cfe7..1d527f194 100644 --- a/internal/jujuclient/ping.go +++ b/internal/jujuclient/ping.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient diff --git a/internal/jujuclient/ping_test.go b/internal/jujuclient/ping_test.go index c3401cc08..a87257498 100644 --- a/internal/jujuclient/ping_test.go +++ b/internal/jujuclient/ping_test.go @@ -1,13 +1,14 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient_test import ( "context" - "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/juju/names/v5" gc "gopkg.in/check.v1" + + "github.com/canonical/jimm/v3/internal/dbmodel" ) type pingSuite struct { diff --git a/internal/jujuclient/storage.go b/internal/jujuclient/storage.go index 5ffdf25e5..b96ba5442 100644 --- a/internal/jujuclient/storage.go +++ b/internal/jujuclient/storage.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient diff --git a/internal/jujuclient/storage_test.go b/internal/jujuclient/storage_test.go index 272499720..dd6b721ae 100644 --- a/internal/jujuclient/storage_test.go +++ b/internal/jujuclient/storage_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package jujuclient_test import ( diff --git a/internal/kubetest/kubetest.go b/internal/kubetest/kubetest.go index d6442e610..a57489f39 100644 --- a/internal/kubetest/kubetest.go +++ b/internal/kubetest/kubetest.go @@ -1,4 +1,4 @@ -// Copyright 2018 Canonical Ltd. +// Copyright 2024 Canonical. package kubetest @@ -12,6 +12,7 @@ import ( const ( Username = "test-kubernetes-user" + //nolint:gosec // Thinks it's an exposed secret. Password = "test-kubernetes-password" ) @@ -32,7 +33,8 @@ func NewFakeKubernetes(c *gc.C) *httptest.Server { return } w.Header().Set("Content-Type", req.Header.Get("Content-Type")) - io.Copy(w, req.Body) + _, err := io.Copy(w, req.Body) + c.Assert(err, gc.IsNil) })) return srv } diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 3d703930a..16406109d 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. // Package logger contains logger adapters for various services. package logger diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 11d354e53..44120ffbf 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -1,22 +1,22 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package middleware import ( "net/http" + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" "github.com/juju/zaputil/zapctx" "go.uber.org/zap" "github.com/canonical/jimm/v3/internal/auth" "github.com/canonical/jimm/v3/internal/jujuapi" - rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" ) // AuthenticateViaCookie performs browser session authentication and puts an identity in the request's context func AuthenticateViaCookie(next http.Handler, jimm jujuapi.JIMM) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx, err := jimm.OAuthAuthenticationService().AuthenticateBrowserSession(r.Context(), w, r) + ctx, err := jimm.AuthenticateBrowserSession(r.Context(), w, r) if err != nil { zapctx.Error(ctx, "failed to authenticate", zap.Error(err)) http.Error(w, "failed to authenticate", http.StatusUnauthorized) @@ -41,7 +41,7 @@ func AuthenticateRebac(next http.Handler, jimm jujuapi.JIMM) http.Handler { return } - user, err := jimm.GetUser(ctx, identity) + user, err := jimm.UserLogin(ctx, identity) if err != nil { zapctx.Error(ctx, "failed to get openfga user", zap.Error(err)) http.Error(w, "internal authentication error", http.StatusInternalServerError) @@ -49,13 +49,9 @@ func AuthenticateRebac(next http.Handler, jimm jujuapi.JIMM) http.Handler { } if !user.JimmAdmin { w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("user is not an admin")) + _, _ = w.Write([]byte("user is not an admin")) return } - err = jimm.UpdateUserLastLogin(ctx, identity) - if err != nil { - zapctx.Error(ctx, "failed to update user last login", zap.Error(err)) - } ctx = rebac_handlers.ContextWithIdentity(ctx, user) next.ServeHTTP(w, r.WithContext(ctx)) diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go index f38454fc7..a622f542b 100644 --- a/internal/middleware/auth_test.go +++ b/internal/middleware/auth_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package middleware_test @@ -9,60 +9,52 @@ import ( "net/http/httptest" "testing" + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" qt "github.com/frankban/quicktest" "github.com/canonical/jimm/v3/internal/auth" "github.com/canonical/jimm/v3/internal/dbmodel" - "github.com/canonical/jimm/v3/internal/jimm" "github.com/canonical/jimm/v3/internal/jimmtest" + "github.com/canonical/jimm/v3/internal/jimmtest/mocks" "github.com/canonical/jimm/v3/internal/middleware" "github.com/canonical/jimm/v3/internal/openfga" - rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" ) // Checks if the authenticator responsible for access control to rebac admin handlers works correctly. func TestAuthenticateRebac(t *testing.T) { testUser := "test-user@canonical.com" tests := []struct { - name string - setupMock func(*jimmtest.MockOAuthAuthenticator) - jimmAdmin bool - expectedStatus int + name string + mockAuthBrowserSession func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) + jimmAdmin bool + expectedStatus int }{ { name: "success", - setupMock: func(m *jimmtest.MockOAuthAuthenticator) { - m.AuthenticateBrowserSession_ = func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { - return auth.ContextWithSessionIdentity(ctx, testUser), nil - } + mockAuthBrowserSession: func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { + return auth.ContextWithSessionIdentity(ctx, testUser), nil }, jimmAdmin: true, expectedStatus: http.StatusOK, }, { name: "failure", - setupMock: func(m *jimmtest.MockOAuthAuthenticator) { - m.AuthenticateBrowserSession_ = func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { - return ctx, errors.New("some error") - } + mockAuthBrowserSession: func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { + return ctx, errors.New("some error") }, expectedStatus: http.StatusUnauthorized, }, { name: "no identity", - setupMock: func(m *jimmtest.MockOAuthAuthenticator) { - m.AuthenticateBrowserSession_ = func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { - return ctx, nil - } + mockAuthBrowserSession: func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { + return ctx, nil }, expectedStatus: http.StatusInternalServerError, }, { name: "not a jimm admin", - setupMock: func(m *jimmtest.MockOAuthAuthenticator) { - m.AuthenticateBrowserSession_ = func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { - return auth.ContextWithSessionIdentity(ctx, testUser), nil - } + mockAuthBrowserSession: func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { + return auth.ContextWithSessionIdentity(ctx, testUser), nil }, jimmAdmin: false, expectedStatus: http.StatusUnauthorized, @@ -73,20 +65,16 @@ func TestAuthenticateRebac(t *testing.T) { t.Run(tt.name, func(t *testing.T) { c := qt.New(t) - mockAuthService := jimmtest.NewMockOAuthAuthenticator(nil, nil) - tt.setupMock(&mockAuthService) - j := jimmtest.JIMM{ - OAuthAuthenticationService_: func() jimm.OAuthAuthenticator { - return &mockAuthService + LoginService: mocks.LoginService{ + AuthenticateBrowserSession_: func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { + return tt.mockAuthBrowserSession(ctx, w, req) + }, }, - GetUser_: func(ctx context.Context, username string) (*openfga.User, error) { + UserLogin_: func(ctx context.Context, username string) (*openfga.User, error) { user := dbmodel.Identity{Name: username} return &openfga.User{Identity: &user, JimmAdmin: tt.jimmAdmin}, nil }, - UpdateUserLastLogin_: func(ctx context.Context, identifier string) error { - return nil - }, } req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() diff --git a/internal/openfga/export_test.go b/internal/openfga/export_test.go index a4a850133..4669e08b2 100644 --- a/internal/openfga/export_test.go +++ b/internal/openfga/export_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 canonical. +// Copyright 2024 Canonical. package openfga diff --git a/internal/openfga/names/common.go b/internal/openfga/names/common.go index ca6e7619a..199333cc4 100644 --- a/internal/openfga/names/common.go +++ b/internal/openfga/names/common.go @@ -1,4 +1,4 @@ -// Copyright 2024 canonical. +// Copyright 2024 Canonical. package names diff --git a/internal/openfga/names/export_test.go b/internal/openfga/names/export_test.go index 024e9e20e..247588021 100644 --- a/internal/openfga/names/export_test.go +++ b/internal/openfga/names/export_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 canonical. +// Copyright 2024 Canonical. package names diff --git a/internal/openfga/names/names.go b/internal/openfga/names/names.go index fb68c00fa..ca4206e87 100644 --- a/internal/openfga/names/names.go +++ b/internal/openfga/names/names.go @@ -1,4 +1,4 @@ -// Copyright 2023 canonical. +// Copyright 2024 Canonical. // Package names holds functions used by other jimm components to // create valid OpenFGA tags. @@ -7,12 +7,12 @@ package names import ( "fmt" - "github.com/canonical/jimm/v3/internal/errors" - jimmnames "github.com/canonical/jimm/v3/pkg/names" cofga "github.com/canonical/ofga" - "github.com/juju/juju/core/permission" "github.com/juju/names/v5" + + "github.com/canonical/jimm/v3/internal/errors" + jimmnames "github.com/canonical/jimm/v3/pkg/names" ) // Relation Types diff --git a/internal/openfga/names/names_test.go b/internal/openfga/names/names_test.go index 4152a48f4..522140c99 100644 --- a/internal/openfga/names/names_test.go +++ b/internal/openfga/names/names_test.go @@ -1,17 +1,17 @@ -// Copyright 2023 canonical. +// Copyright 2024 Canonical. package names_test import ( "testing" + "github.com/google/uuid" + "github.com/juju/juju/core/permission" "github.com/juju/names/v5" gc "gopkg.in/check.v1" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" jimmnames "github.com/canonical/jimm/v3/pkg/names" - "github.com/google/uuid" - "github.com/juju/juju/core/permission" ) func Test(t *testing.T) { diff --git a/internal/openfga/openfga.go b/internal/openfga/openfga.go index 4b17b1094..103b8ee44 100644 --- a/internal/openfga/openfga.go +++ b/internal/openfga/openfga.go @@ -1,4 +1,4 @@ -// Copyright 2023 canonical. +// Copyright 2024 Canonical. package openfga @@ -6,12 +6,12 @@ import ( "context" "strings" - "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/servermon" cofga "github.com/canonical/ofga" "github.com/juju/names/v5" + "github.com/canonical/jimm/v3/internal/errors" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" + "github.com/canonical/jimm/v3/internal/servermon" jimmnames "github.com/canonical/jimm/v3/pkg/names" ) @@ -206,6 +206,7 @@ func (o *OFGAClient) removeTuples(ctx context.Context, tuple Tuple) (err error) for { // Since we're deleting the returned tuples, it's best to avoid pagination, // and fresh query for the relations. + //nolint:gosec // The page size will not exceed int32. tuples, ct, err := o.ReadRelatedObjects(ctx, tuple, int32(pageSize), "") if err != nil { return err diff --git a/internal/openfga/openfga_test.go b/internal/openfga/openfga_test.go index 11cd5c0bb..cc4925263 100644 --- a/internal/openfga/openfga_test.go +++ b/internal/openfga/openfga_test.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package openfga_test import ( @@ -71,7 +72,7 @@ func (suite *openFGATestSuite) TestRemovingTuplesFromOFGASucceeds(c *gc.C) { groupUUID := uuid.NewString() - //Create tuples before writing to db + // Create tuples before writing to db user1 := ofganames.ConvertTag(names.NewUserTag("bob")) tuple1 := openfga.Tuple{ Object: user1, @@ -86,14 +87,14 @@ func (suite *openFGATestSuite) TestRemovingTuplesFromOFGASucceeds(c *gc.C) { Target: ofganames.ConvertTag(jimmnames.NewGroupTag(groupUUID)), } - //Delete before insert should fail + // Delete before insert should fail err := suite.ofgaClient.RemoveRelation(ctx, tuple1, tuple2) c.Assert(strings.Contains(err.Error(), "cannot delete a tuple which does not exist"), gc.Equals, true) err = suite.ofgaClient.AddRelation(ctx, tuple1, tuple2) c.Assert(err, gc.IsNil) - //Delete after insert should succeed. + // Delete after insert should succeed. err = suite.ofgaClient.RemoveRelation(ctx, tuple1, tuple2) c.Assert(err, gc.IsNil) changes, err := suite.cofgaClient.ReadChanges(ctx, "group", 99, "") diff --git a/internal/openfga/user.go b/internal/openfga/user.go index a87d5e900..3e62b918e 100644 --- a/internal/openfga/user.go +++ b/internal/openfga/user.go @@ -1,4 +1,4 @@ -// Copyright 2023 canonical. +// Copyright 2024 Canonical. package openfga @@ -6,6 +6,7 @@ import ( "context" "strings" + "github.com/canonical/ofga" "github.com/juju/names/v5" "github.com/juju/zaputil/zapctx" "go.uber.org/zap" @@ -14,7 +15,6 @@ import ( "github.com/canonical/jimm/v3/internal/errors" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" jimmnames "github.com/canonical/jimm/v3/pkg/names" - "github.com/canonical/ofga" ) // NewUser returns a new user structure that can be used to check diff --git a/internal/openfga/user_test.go b/internal/openfga/user_test.go index e42bec85d..36c1b0cd8 100644 --- a/internal/openfga/user_test.go +++ b/internal/openfga/user_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 canonical. +// Copyright 2024 Canonical. package openfga_test diff --git a/internal/pubsub/hub.go b/internal/pubsub/hub.go index 22ec07221..828fbe9b0 100644 --- a/internal/pubsub/hub.go +++ b/internal/pubsub/hub.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. // Package pubsub contains an implementation of a simple pubsub // mechanism that passes messages about models between @@ -8,8 +8,9 @@ package pubsub import ( "sync" - "github.com/canonical/jimm/v3/internal/errors" "github.com/juju/utils/v2/parallel" + + "github.com/canonical/jimm/v3/internal/errors" ) // HandlerFunc takes two arguments - a model ID and the message about this model. diff --git a/internal/pubsub/hub_test.go b/internal/pubsub/hub_test.go index da07eecba..0a687397b 100644 --- a/internal/pubsub/hub_test.go +++ b/internal/pubsub/hub_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package pubsub_test diff --git a/internal/rebac_admin/backend.go b/internal/rebac_admin/backend.go index e26c6ed19..0af81f3b1 100644 --- a/internal/rebac_admin/backend.go +++ b/internal/rebac_admin/backend.go @@ -1,16 +1,16 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package rebac_admin import ( "context" + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" "github.com/juju/zaputil/zapctx" "go.uber.org/zap" "github.com/canonical/jimm/v3/internal/errors" "github.com/canonical/jimm/v3/internal/jujuapi" - rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" ) func SetupBackend(ctx context.Context, jimm jujuapi.JIMM) (*rebac_handlers.ReBACAdminBackend, error) { diff --git a/internal/rebac_admin/entitlements.go b/internal/rebac_admin/entitlements.go index fe4fa8b37..9f0d930a6 100644 --- a/internal/rebac_admin/entitlements.go +++ b/internal/rebac_admin/entitlements.go @@ -1,19 +1,14 @@ -// Copyright 2024 canonical. +// Copyright 2024 Canonical. package rebac_admin import ( "context" - openfgastatic "github.com/canonical/jimm/v3/openfga" "github.com/canonical/rebac-admin-ui-handlers/v1/resources" -) -// Since these values have semantic meanings in the API, they'll probably be -// refactored into constants provided by `rebac-admin-ui-handlers` library. So, -// we define them here as constants, rather than repeating them as literals. -const identity = "identity" -const group = "group" + openfgastatic "github.com/canonical/jimm/v3/openfga" +) // For rebac v1 this list is kept manually. // The reason behind that is we want to decide what relations to expose to rebac admin ui. diff --git a/internal/rebac_admin/export_test.go b/internal/rebac_admin/export_test.go index 812b2170f..953b447e0 100644 --- a/internal/rebac_admin/export_test.go +++ b/internal/rebac_admin/export_test.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package rebac_admin var ( diff --git a/internal/rebac_admin/groups.go b/internal/rebac_admin/groups.go index f7eb8e0ac..135a00cdc 100644 --- a/internal/rebac_admin/groups.go +++ b/internal/rebac_admin/groups.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package rebac_admin @@ -159,7 +159,7 @@ func (s *groupsService) GetGroupIdentities(ctx context.Context, groupId string, Relation: ofganames.MemberRelation.String(), TargetObject: groupTag.String(), } - identities, nextToken, err := s.jimm.ListRelationshipTuples(ctx, user, tuple, int32(filter.Limit()), filter.Token()) + identities, nextToken, err := s.jimm.ListRelationshipTuples(ctx, user, tuple, int32(filter.Limit()), filter.Token()) // #nosec G115 accept integer conversion if err != nil { return nil, err } @@ -253,7 +253,8 @@ func (s *groupsService) GetGroupEntitlements(ctx context.Context, groupId string filter := utils.CreateTokenPaginationFilter(params.Size, params.NextToken, params.NextPageToken) group := ofganames.WithMemberRelation(jimmnames.NewGroupTag(groupId)) entitlementToken := pagination.NewEntitlementToken(filter.Token()) - tuples, nextEntitlmentToken, err := s.jimm.ListObjectRelations(ctx, user, group, int32(filter.Limit()), entitlementToken) + // nolint:gosec accept integer conversion + tuples, nextEntitlmentToken, err := s.jimm.ListObjectRelations(ctx, user, group, int32(filter.Limit()), entitlementToken) // #nosec G115 accept integer conversion if err != nil { return nil, err } diff --git a/internal/rebac_admin/groups_integration_test.go b/internal/rebac_admin/groups_integration_test.go index b22b95085..b14b6c86f 100644 --- a/internal/rebac_admin/groups_integration_test.go +++ b/internal/rebac_admin/groups_integration_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package rebac_admin_test @@ -6,6 +6,9 @@ import ( "context" "fmt" + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + "github.com/juju/names/v5" gc "gopkg.in/check.v1" "github.com/canonical/jimm/v3/internal/jimmtest" @@ -13,9 +16,6 @@ import ( ofganames "github.com/canonical/jimm/v3/internal/openfga/names" "github.com/canonical/jimm/v3/internal/rebac_admin" jimmnames "github.com/canonical/jimm/v3/pkg/names" - rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" - "github.com/canonical/rebac-admin-ui-handlers/v1/resources" - "github.com/juju/names/v5" ) type rebacAdminSuite struct { diff --git a/internal/rebac_admin/groups_test.go b/internal/rebac_admin/groups_test.go index fe0be0b0f..3fc964bbb 100644 --- a/internal/rebac_admin/groups_test.go +++ b/internal/rebac_admin/groups_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package rebac_admin_test diff --git a/internal/rebac_admin/identities.go b/internal/rebac_admin/identities.go index eb6f7d080..7751a9d67 100644 --- a/internal/rebac_admin/identities.go +++ b/internal/rebac_admin/identities.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package rebac_admin @@ -6,18 +6,18 @@ import ( "context" "fmt" + v1 "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + "github.com/juju/names/v5" + "github.com/juju/zaputil/zapctx" + "go.uber.org/zap" + "github.com/canonical/jimm/v3/internal/common/pagination" "github.com/canonical/jimm/v3/internal/jujuapi" "github.com/canonical/jimm/v3/internal/openfga" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" "github.com/canonical/jimm/v3/internal/rebac_admin/utils" apiparams "github.com/canonical/jimm/v3/pkg/api/params" - "github.com/juju/names/v5" - - v1 "github.com/canonical/rebac-admin-ui-handlers/v1" - "github.com/canonical/rebac-admin-ui-handlers/v1/resources" - "github.com/juju/zaputil/zapctx" - "go.uber.org/zap" ) type identitiesService struct { @@ -115,7 +115,7 @@ func (s *identitiesService) GetIdentityGroups(ctx context.Context, identityId st Object: objUser.ResourceTag().String(), Relation: ofganames.MemberRelation.String(), TargetObject: openfga.GroupType.String(), - }, int32(filter.Limit()), filter.Token()) + }, int32(filter.Limit()), filter.Token()) // #nosec G115 accept integer conversion if err != nil { return nil, err @@ -195,7 +195,7 @@ func (s *identitiesService) GetIdentityEntitlements(ctx context.Context, identit filter := utils.CreateTokenPaginationFilter(params.Size, params.NextToken, params.NextPageToken) entitlementToken := pagination.NewEntitlementToken(filter.Token()) - tuples, nextEntitlmentToken, err := s.jimm.ListObjectRelations(ctx, user, objUser.Tag().String(), int32(filter.Limit()), entitlementToken) + tuples, nextEntitlmentToken, err := s.jimm.ListObjectRelations(ctx, user, objUser.Tag().String(), int32(filter.Limit()), entitlementToken) // #nosec G115 accept integer conversion if err != nil { return nil, err } diff --git a/internal/rebac_admin/identities_integration_test.go b/internal/rebac_admin/identities_integration_test.go index 236f682c1..c7b8b248c 100644 --- a/internal/rebac_admin/identities_integration_test.go +++ b/internal/rebac_admin/identities_integration_test.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package rebac_admin_test import ( @@ -244,7 +245,7 @@ func (s *identitiesSuite) TestPatchIdentityEntitlements(c *gc.C) { ctx := context.Background() identitySvc := rebac_admin.NewidentitiesService(s.JIMM) tester := jimmtest.GocheckTester{C: c} - env := jimmtest.ParseEnvironment(tester, patchGroupEntitlementTestEnv) + env := jimmtest.ParseEnvironment(tester, patchIdentitiesEntitlementTestEnv) env.PopulateDB(tester, s.JIMM.Database) oldModels := []string{env.Models[0].UUID, env.Models[1].UUID} newModels := []string{env.Models[2].UUID, env.Models[3].UUID} diff --git a/internal/rebac_admin/identities_test.go b/internal/rebac_admin/identities_test.go index 2c4a01bd5..d039b18f1 100644 --- a/internal/rebac_admin/identities_test.go +++ b/internal/rebac_admin/identities_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package rebac_admin_test diff --git a/internal/rebac_admin/package_test.go b/internal/rebac_admin/package_test.go index 2f0c766a1..4c66d1819 100644 --- a/internal/rebac_admin/package_test.go +++ b/internal/rebac_admin/package_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package rebac_admin_test diff --git a/internal/rebac_admin/utils/auth.go b/internal/rebac_admin/utils/auth.go index 66a743760..376a778e4 100644 --- a/internal/rebac_admin/utils/auth.go +++ b/internal/rebac_admin/utils/auth.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package utils @@ -6,8 +6,9 @@ import ( "context" "errors" - "github.com/canonical/jimm/v3/internal/openfga" rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" + + "github.com/canonical/jimm/v3/internal/openfga" ) // GetUserFromContext retrieves the OpenFGA user pointer from the context diff --git a/internal/rebac_admin/utils/errors.go b/internal/rebac_admin/utils/errors.go index 0bdeb0a02..7c4c86c4e 100644 --- a/internal/rebac_admin/utils/errors.go +++ b/internal/rebac_admin/utils/errors.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package utils import "errors" diff --git a/internal/rebac_admin/utils/types.go b/internal/rebac_admin/utils/types.go index 2266eb913..3e02a7a3f 100644 --- a/internal/rebac_admin/utils/types.go +++ b/internal/rebac_admin/utils/types.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package utils import ( diff --git a/internal/rebac_admin/utils/utils.go b/internal/rebac_admin/utils/utils.go index 180dab6ec..11bde5059 100644 --- a/internal/rebac_admin/utils/utils.go +++ b/internal/rebac_admin/utils/utils.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package utils diff --git a/internal/rpc/client.go b/internal/rpc/client.go index 366e6caad..61ec4f646 100644 --- a/internal/rpc/client.go +++ b/internal/rpc/client.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package rpc @@ -12,6 +12,8 @@ import ( "github.com/gorilla/websocket" jujuparams "github.com/juju/juju/rpc/params" + "github.com/juju/zaputil/zapctx" + "go.uber.org/zap" "github.com/canonical/jimm/v3/internal/errors" ) @@ -99,7 +101,10 @@ func (c *Client) handleError(err error) { if !c.closing { // We haven't sent a close message yet, so try to send one. cm := websocket.FormatCloseMessage(websocket.CloseProtocolError, err.Error()) - c.conn.WriteControl(websocket.CloseMessage, cm, time.Time{}) + err := c.conn.WriteControl(websocket.CloseMessage, cm, time.Time{}) + if err != nil { + zapctx.Error(context.Background(), "failed to write socket closure message", zap.Error(err)) + } } c.err = err c.conn.Close() @@ -128,7 +133,10 @@ func (c *Client) handleRequest(msg *message) { // Note we're ignoring any write error here as any subsequent write // will also error and that will be able to process the error more // appropriately. - c.conn.WriteJSON(resp) + err := c.conn.WriteJSON(resp) + if err != nil { + zapctx.Error(context.Background(), "failed to write JSON resp", zap.Error(err)) + } } func (c *Client) handleResponse(msg *message) { @@ -181,6 +189,7 @@ func (c *Client) Call(ctx context.Context, facade string, version int, id, metho return errors.E(op, err) } ch := make(chan struct{}) + //nolint:staticcheck // Not sure why Martin made this a **. Ignore for now. respMsg := new(*message) c.msgs[req.RequestID] = inflight{ ch: ch, @@ -194,28 +203,26 @@ func (c *Client) Call(ctx context.Context, facade string, version int, id, metho select { case <-ch: - if respMsg != nil { - permissionsRequired, err := checkPermissionsRequired(ctx, *respMsg) - if err != nil { - return err - } - if permissionsRequired != nil { - return &Error{ - Code: PermissionCheckRequiredErrorCode, - Info: permissionsRequired, - } + permissionsRequired, err := checkPermissionsRequired(ctx, *respMsg) + if err != nil { + return err + } + if permissionsRequired != nil { + return &Error{ + Code: PermissionCheckRequiredErrorCode, + Info: permissionsRequired, } - if (*respMsg).Error != "" { - return &Error{ - Message: (*respMsg).Error, - Code: (*respMsg).ErrorCode, - Info: (*respMsg).ErrorInfo, - } + } + if (*respMsg).Error != "" { + return &Error{ + Message: (*respMsg).Error, + Code: (*respMsg).ErrorCode, + Info: (*respMsg).ErrorInfo, } - if resp != nil { - if err := json.Unmarshal([]byte((*respMsg).Response), &resp); err != nil { - return errors.E(op, err) - } + } + if resp != nil { + if err := json.Unmarshal([]byte((*respMsg).Response), &resp); err != nil { + return errors.E(op, err) } } return nil diff --git a/internal/rpc/client_test.go b/internal/rpc/client_test.go index 66e7f0403..79cfebed0 100644 --- a/internal/rpc/client_test.go +++ b/internal/rpc/client_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package rpc_test @@ -244,15 +244,14 @@ func TestProxySockets(t *testing.T) { c := qt.New(t) ctx := context.Background() - srvController := newServer(func(conn *websocket.Conn) error { - return echo(conn) - }) + srvController := newServer(echo) + errChan := make(chan error) srvJIMM := newServer(func(connClient *websocket.Conn) error { testTokenGen := testTokenGenerator{} f := func(context.Context) (rpc.WebsocketConnectionWithMetadata, error) { connController, err := srvController.dialer.DialWebsocket(ctx, srvController.URL) - c.Assert(err, qt.IsNil) + c.Check(err, qt.IsNil) return rpc.WebsocketConnectionWithMetadata{ Conn: connController, ModelName: "TestName", @@ -264,8 +263,10 @@ func TestProxySockets(t *testing.T) { TokenGen: &testTokenGen, ConnectController: f, AuditLog: auditLogger, + LoginService: &mockLoginService{}, } err := rpc.ProxySockets(ctx, proxyHelpers) + c.Check(err, qt.ErrorMatches, "error reading from (client|controller).*") errChan <- err return err }) @@ -281,8 +282,17 @@ func TestProxySockets(t *testing.T) { err = ws.WriteJSON(&msg) c.Assert(err, qt.IsNil) resp := rpc.Message{} - err = ws.ReadJSON(&resp) - c.Assert(err, qt.IsNil) + receiveChan := make(chan error) + go func() { + receiveChan <- ws.ReadJSON(&resp) + }() + select { + case err := <-receiveChan: + c.Assert(err, qt.IsNil) + case <-time.After(5 * time.Second): + c.Logf("took too long to read response") + c.FailNow() + } c.Assert(resp.Response, qt.DeepEquals, msg.Params) ws.Close() <-errChan // Ensure go routines are cleaned up @@ -293,16 +303,14 @@ func TestCancelProxySockets(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - srvController := newServer(func(conn *websocket.Conn) error { - return echo(conn) - }) + srvController := newServer(echo) errChan := make(chan error) srvJIMM := newServer(func(connClient *websocket.Conn) error { testTokenGen := testTokenGenerator{} f := func(context.Context) (rpc.WebsocketConnectionWithMetadata, error) { connController, err := srvController.dialer.DialWebsocket(ctx, srvController.URL) - c.Assert(err, qt.IsNil) + c.Check(err, qt.IsNil) return rpc.WebsocketConnectionWithMetadata{ Conn: connController, ModelName: "TestName", @@ -314,8 +322,10 @@ func TestCancelProxySockets(t *testing.T) { TokenGen: &testTokenGen, ConnectController: f, AuditLog: auditLogger, + LoginService: &mockLoginService{}, } err := rpc.ProxySockets(ctx, proxyHelpers) + c.Check(err, qt.ErrorMatches, "Context cancelled") errChan <- err return err }) @@ -326,8 +336,7 @@ func TestCancelProxySockets(t *testing.T) { c.Assert(err, qt.IsNil) defer ws.Close() cancel() - err = <-errChan - c.Assert(err.Error(), qt.Equals, "Context cancelled") + <-errChan } func TestProxySocketsAuditLogs(t *testing.T) { @@ -335,17 +344,16 @@ func TestProxySocketsAuditLogs(t *testing.T) { ctx := context.Background() - srvController := newServer(func(conn *websocket.Conn) error { - return echo(conn) - }) + srvController := newServer(echo) auditLogs := make([]*dbmodel.AuditLogEntry, 0) errChan := make(chan error) srvJIMM := newServer(func(connClient *websocket.Conn) error { + defer connClient.Close() testTokenGen := testTokenGenerator{} f := func(context.Context) (rpc.WebsocketConnectionWithMetadata, error) { connController, err := srvController.dialer.DialWebsocket(ctx, srvController.URL) - c.Assert(err, qt.IsNil) + c.Check(err, qt.IsNil) return rpc.WebsocketConnectionWithMetadata{ Conn: connController, ModelName: "TestModelName", @@ -357,8 +365,10 @@ func TestProxySocketsAuditLogs(t *testing.T) { TokenGen: &testTokenGen, ConnectController: f, AuditLog: auditLogger, + LoginService: &mockLoginService{}, } err := rpc.ProxySockets(ctx, proxyHelpers) + c.Check(err, qt.ErrorMatches, `error reading from (client|controller).*`) errChan <- err return err }) @@ -428,7 +438,8 @@ func newServer(f func(*websocket.Conn) error) *server { cp.AddCert(srv.Certificate()) srv.dialer = &rpc.Dialer{ TLSConfig: &tls.Config{ - RootCAs: cp, + RootCAs: cp, + MinVersion: tls.VersionTLS12, }, } return &srv @@ -445,15 +456,17 @@ func handleWS(f func(*websocket.Conn) error) http.Handler { defer c.Close() err = f(c) var cm []byte - if err == nil { + switch { + case err == nil: cm = websocket.FormatCloseMessage(websocket.CloseNormalClosure, "") - } else if websocket.IsCloseError(err) { + case websocket.IsCloseError(err): ce := err.(*websocket.CloseError) cm = websocket.FormatCloseMessage(ce.Code, ce.Text) - } else { + default: cm = websocket.FormatCloseMessage(websocket.CloseInternalServerErr, err.Error()) } - c.WriteControl(websocket.CloseMessage, cm, time.Time{}) + _ = c.WriteControl(websocket.CloseMessage, cm, time.Time{}) + }) } diff --git a/internal/rpc/dial.go b/internal/rpc/dial.go index 36e095c76..eb46c0f3b 100644 --- a/internal/rpc/dial.go +++ b/internal/rpc/dial.go @@ -1,4 +1,4 @@ -// Copyright 2023 Canonical Ltd. +// Copyright 2024 Canonical. package rpc @@ -66,6 +66,7 @@ func Dial(ctx context.Context, ctl *dbmodel.Controller, modelTag names.ModelTag, tlsConfig = &tls.Config{ RootCAs: cp, ServerName: ctl.TLSHostname, + MinVersion: tls.VersionTLS12, } } dialer := Dialer{ diff --git a/internal/rpc/export_test.go b/internal/rpc/export_test.go index 0b6ae2f2f..a50d4d277 100644 --- a/internal/rpc/export_test.go +++ b/internal/rpc/export_test.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package rpc type Message message diff --git a/internal/rpc/proxy.go b/internal/rpc/proxy.go index ccba4548f..60d7db1ce 100644 --- a/internal/rpc/proxy.go +++ b/internal/rpc/proxy.go @@ -1,9 +1,11 @@ +// Copyright 2024 Canonical. package rpc import ( "context" "encoding/base64" "encoding/json" + "fmt" "sync" "time" @@ -15,13 +17,10 @@ import ( "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/jimm" - "github.com/canonical/jimm/v3/internal/jimm/credentials" "github.com/canonical/jimm/v3/internal/openfga" "github.com/canonical/jimm/v3/internal/servermon" "github.com/canonical/jimm/v3/internal/utils" apiparams "github.com/canonical/jimm/v3/pkg/api/params" - jimmnames "github.com/canonical/jimm/v3/pkg/names" ) const ( @@ -59,12 +58,14 @@ type WebsocketConnectionWithMetadata struct { ModelName string } -// JIMM represents the JIMM interface used by the proxy. -type JIMM interface { - GetUser(ctx context.Context, identifier string) (*openfga.User, error) - UpdateUserLastLogin(ctx context.Context, identifier string) error - OAuthAuthenticationService() jimm.OAuthAuthenticator - GetCredentialStore() credentials.CredentialStore +// LoginService represents the LoginService interface used by the proxy. +// Currently this is a duplicate of the [jujuapi.LoginService]. +type LoginService interface { + LoginDevice(ctx context.Context) (*oauth2.DeviceAuthResponse, error) + GetDeviceSessionToken(ctx context.Context, deviceOAuthResponse *oauth2.DeviceAuthResponse) (string, error) + LoginClientCredentials(ctx context.Context, clientID string, clientSecret string) (*openfga.User, error) + LoginWithSessionToken(ctx context.Context, sessionToken string) (*openfga.User, error) + LoginWithSessionCookie(ctx context.Context, identityID string) (*openfga.User, error) } // ProxyHelpers contains all the necessary helpers for proxying a Juju client @@ -74,7 +75,7 @@ type ProxyHelpers struct { TokenGen TokenGenerator ConnectController func(context.Context) (WebsocketConnectionWithMetadata, error) AuditLog func(*dbmodel.AuditLogEntry) - JIMM JIMM + LoginService LoginService AuthenticatedIdentityID string } @@ -91,6 +92,10 @@ func ProxySockets(ctx context.Context, helpers ProxyHelpers) error { zapctx.Error(ctx, "Missing audit log function") return errors.E(op, "Missing audit log function") } + if helpers.LoginService == nil { + zapctx.Error(ctx, "Missing login service function") + return errors.E(op, "Missing login service function") + } errChan := make(chan error, 2) msgInFlight := inflightMsgs{messages: make(map[uint64]*message)} client := writeLockConn{conn: helpers.ConnClient} @@ -103,7 +108,7 @@ func ProxySockets(ctx context.Context, helpers ProxyHelpers) error { tokenGen: helpers.TokenGen, auditLog: helpers.AuditLog, conversationId: utils.NewConversationID(), - jimm: helpers.JIMM, + loginService: helpers.LoginService, authenticatedIdentityID: helpers.AuthenticatedIdentityID, }, errChan: errChan, @@ -162,11 +167,16 @@ func (c *writeLockConn) sendMessage(responseObject any, request *message) { responseData, err := json.Marshal(responseObject) if err != nil { errorMsg := createErrResponse(err, request) - c.writeJson(errorMsg) + if err := c.writeJson(errorMsg); err != nil { + zapctx.Error(context.Background(), "failed to send error message in proxy", zap.Error(err)) + } + } msg.Response = responseData } - c.writeJson(msg) + if err := c.writeJson(msg); err != nil { + zapctx.Error(context.Background(), "failed to write message in proxy", zap.Error(err)) + } } // inflightMsgs holds only request messages that are @@ -236,7 +246,7 @@ type modelProxy struct { msgs *inflightMsgs auditLog func(*dbmodel.AuditLogEntry) tokenGen TokenGenerator - jimm JIMM + loginService LoginService modelName string conversationId string authenticatedIdentityID string @@ -251,11 +261,15 @@ func (p *modelProxy) sendError(socket *writeLockConn, req *message, err error) { } msg := createErrResponse(err, req) if msg != nil { - socket.writeJson(msg) + if err := socket.writeJson(msg); err != nil { + zapctx.Error(context.Background(), "failed to create err response message", zap.Error(err)) + } } // An error message is a response back to the client. servermon.JujuCallErrorCount.WithLabelValues(req.Type, req.Request, p.msgs.controllerUUID) - p.auditLogMessage(msg, true) + if err := p.auditLogMessage(msg, true); err != nil { + zapctx.Error(context.Background(), "failed to audit log message", zap.Error(err)) + } } func (p *modelProxy) auditLogMessage(msg *message, isResponse bool) error { @@ -316,7 +330,6 @@ type clientProxy struct { // start begins the client->controller proxier. func (p *clientProxy) start(ctx context.Context) error { - const op = errors.Op("rpc.clientProxy.start") defer func() { if p.dst != nil { p.dst.conn.Close() @@ -327,16 +340,18 @@ func (p *clientProxy) start(ctx context.Context) error { msg := new(message) if err := p.src.readJson(&msg); err != nil { // Error reading on the socket implies it is closed, simply return. - return err + return fmt.Errorf("error reading from client: %w", err) } zapctx.Debug(ctx, "Read message from client", zap.Any("message", msg)) err := p.makeControllerConnection(ctx) if err != nil { zapctx.Error(ctx, "error connecting to controller", zap.Error(err)) p.sendError(p.src, msg, err) - return err + return fmt.Errorf("failed to connect to controller: %w", err) + } + if err := p.auditLogMessage(msg, false); err != nil { + zapctx.Error(ctx, "failed to audit log message", zap.Error(err)) } - p.auditLogMessage(msg, false) // All requests should be proxied as transparently as possible through to the controller // except for auth related requests like Login because JIMM is auth gateway. if msg.Type == "Admin" { @@ -424,7 +439,7 @@ func (p *controllerProxy) start(ctx context.Context) error { msg := new(message) if err := p.src.readJson(msg); err != nil { // Error reading on the socket implies it is closed, simply return. - return err + return fmt.Errorf("error reading from controller: %w", err) } zapctx.Debug(ctx, "Received message from controller", zap.Any("Message", msg)) permissionsRequired, err := checkPermissionsRequired(ctx, msg) @@ -443,7 +458,9 @@ func (p *controllerProxy) start(ctx context.Context) error { // Write back to the controller. msg := p.msgs.getMessage(msg.RequestID) if msg != nil { - p.src.writeJson(msg) + if err := p.src.writeJson(msg); err != nil { + zapctx.Error(context.Background(), "failed to write back to controller", zap.Error(err)) + } } continue } else { @@ -451,15 +468,17 @@ func (p *controllerProxy) start(ctx context.Context) error { zapctx.Error(ctx, "Failed to modify message", zap.Error(err)) p.handleError(msg, err) // An error when modifying the message is a show stopper. - return err + return fmt.Errorf("error modifying controller response: %w", err) } } p.msgs.removeMessage(msg.RequestID) - p.auditLogMessage(msg, true) + if err := p.auditLogMessage(msg, true); err != nil { + zapctx.Error(context.Background(), "failed to audit log message", zap.Error(err)) + } zapctx.Debug(ctx, "Writing modified message to client", zap.Any("Message", msg)) if err := p.dst.writeJson(msg); err != nil { zapctx.Error(ctx, "controllerProxy error writing to dst", zap.Error(err)) - return err + return fmt.Errorf("error writing message to client: %w", err) } } } @@ -490,13 +509,15 @@ func checkPermissionsRequired(ctx context.Context, msg *message) (map[string]any var er params.ErrorResults err := json.Unmarshal(msg.Response, &er) if err != nil { - zapctx.Error(ctx, "failed to read response error") + zapctx.Error(ctx, "failed to read response error", zap.Error(err)) return permissionMap, nil } // Check for errors that may be a result of a bulk request. for _, e := range er.Results { - zapctx.Debug(ctx, "received error", zap.Any("error", e)) + if e.Error != nil { + zapctx.Debug(ctx, "received error", zap.Any("error", e.Error)) + } if e.Error != nil && e.Error.Code == accessRequiredErrorCode { for k, v := range e.Error.Info { accessLevel, ok := v.(string) @@ -594,7 +615,18 @@ func (p *clientProxy) handleAdminFacade(ctx context.Context, msg *message) (clie errorFnc := func(err error) (*message, *message, error) { return nil, nil, err } - controllerLoginMessageFnc := func(data []byte) (*message, *message, error) { + controllerLoginMessageFnc := func(user *openfga.User) (*message, *message, error) { + jwt, err := p.tokenGen.MakeLoginToken(ctx, user) + if err != nil { + return errorFnc(err) + } + data, err := json.Marshal(params.LoginRequest{ + AuthTag: names.NewUserTag(user.Name).String(), + Token: base64.StdEncoding.EncodeToString(jwt), + }) + if err != nil { + return errorFnc(err) + } m := *msg m.Type = "Admin" m.Request = "Login" @@ -604,7 +636,7 @@ func (p *clientProxy) handleAdminFacade(ctx context.Context, msg *message) (clie } switch msg.Request { case "LoginDevice": - deviceResponse, err := jimm.LoginDevice(ctx, p.jimm.OAuthAuthenticationService()) + deviceResponse, err := p.loginService.LoginDevice(ctx) if err != nil { return errorFnc(err) } @@ -620,7 +652,7 @@ func (p *clientProxy) handleAdminFacade(ctx context.Context, msg *message) (clie msg.Response = data return msg, nil, nil case "GetDeviceSessionToken": - sessionToken, err := jimm.GetDeviceSessionToken(ctx, p.jimm.OAuthAuthenticationService(), p.jimm.GetCredentialStore(), p.deviceOAuthResponse) + sessionToken, err := p.loginService.GetDeviceSessionToken(ctx, p.deviceOAuthResponse) if err != nil { return errorFnc(err) } @@ -639,95 +671,31 @@ func (p *clientProxy) handleAdminFacade(ctx context.Context, msg *message) (clie return errorFnc(err) } - // Verify the session token - token, err := p.jimm.OAuthAuthenticationService().VerifySessionToken(request.SessionToken) + user, err := p.loginService.LoginWithSessionToken(ctx, request.SessionToken) if err != nil { return errorFnc(err) } - email := token.Subject() - user, err := p.jimm.GetUser(ctx, email) - if err != nil { - return errorFnc(err) - } - err = p.jimm.UpdateUserLastLogin(ctx, email) - if err != nil { - return errorFnc(err) - } - - jwt, err := p.tokenGen.MakeLoginToken(ctx, user) - if err != nil { - return errorFnc(err) - } - data, err := json.Marshal(params.LoginRequest{ - AuthTag: names.NewUserTag(email).String(), - Token: base64.StdEncoding.EncodeToString(jwt), - }) - if err != nil { - return errorFnc(err) - } - return controllerLoginMessageFnc(data) + return controllerLoginMessageFnc(user) case "LoginWithClientCredentials": var request apiparams.LoginWithClientCredentialsRequest err := json.Unmarshal(msg.Params, &request) if err != nil { return errorFnc(err) } - clientIdWithDomain, err := jimmnames.EnsureValidServiceAccountId(request.ClientID) - if err != nil { - return errorFnc(err) - } - err = p.jimm.OAuthAuthenticationService().VerifyClientCredentials(ctx, request.ClientID, request.ClientSecret) + user, err := p.loginService.LoginClientCredentials(ctx, request.ClientID, request.ClientSecret) if err != nil { return errorFnc(err) } - user, err := p.jimm.GetUser(ctx, clientIdWithDomain) - if err != nil { - return errorFnc(err) - } - err = p.jimm.UpdateUserLastLogin(ctx, clientIdWithDomain) - if err != nil { - return errorFnc(err) - } - - jwt, err := p.tokenGen.MakeLoginToken(ctx, user) - if err != nil { - return errorFnc(err) - } - data, err := json.Marshal(params.LoginRequest{ - AuthTag: names.NewUserTag(clientIdWithDomain).String(), - Token: base64.StdEncoding.EncodeToString(jwt), - }) - if err != nil { - return errorFnc(err) - } - return controllerLoginMessageFnc(data) + return controllerLoginMessageFnc(user) case "LoginWithSessionCookie": - if p.modelProxy.authenticatedIdentityID == "" { - return errorFnc(errors.E(errors.CodeUnauthorized)) - } - user, err := p.jimm.GetUser(ctx, p.modelProxy.authenticatedIdentityID) - if err != nil { - return errorFnc(err) - } - err = p.jimm.UpdateUserLastLogin(ctx, p.modelProxy.authenticatedIdentityID) + user, err := p.loginService.LoginWithSessionCookie(ctx, p.modelProxy.authenticatedIdentityID) if err != nil { return errorFnc(err) } - jwt, err := p.tokenGen.MakeLoginToken(ctx, user) - if err != nil { - return errorFnc(err) - } - data, err := json.Marshal(params.LoginRequest{ - AuthTag: user.ResourceTag().String(), - Token: base64.StdEncoding.EncodeToString(jwt), - }) - if err != nil { - return errorFnc(err) - } - return controllerLoginMessageFnc(data) + return controllerLoginMessageFnc(user) case "Login": return errorFnc(errors.E("JIMM does not support login from old clients", errors.CodeNotSupported)) default: diff --git a/internal/rpc/proxy_test.go b/internal/rpc/proxy_test.go index fb948361f..7d7ab6238 100644 --- a/internal/rpc/proxy_test.go +++ b/internal/rpc/proxy_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package rpc_test @@ -9,22 +9,18 @@ import ( "testing" "time" - "github.com/coreos/go-oidc/v3/oidc" qt "github.com/frankban/quicktest" "github.com/google/uuid" "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" - "github.com/lestrrat-go/jwx/v2/jwt" "golang.org/x/oauth2" "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/jimm" - "github.com/canonical/jimm/v3/internal/jimm/credentials" - "github.com/canonical/jimm/v3/internal/jimmtest" "github.com/canonical/jimm/v3/internal/openfga" "github.com/canonical/jimm/v3/internal/rpc" apiparams "github.com/canonical/jimm/v3/pkg/api/params" + jimmnames "github.com/canonical/jimm/v3/pkg/names" ) type message struct { @@ -238,9 +234,11 @@ func TestProxySocketsAdminFacade(t *testing.T) { for _, test := range tests { t.Run(test.about, func(t *testing.T) { ctx := context.Background() + ctx, cancelFunc := context.WithCancel(ctx) + defer cancelFunc() clientWebsocket := newMockWebsocketConnection(10) controllerWebsocket := newMockWebsocketConnection(10) - authenticator := &mockOAuthAuthenticator{ + loginSvc := &mockLoginService{ email: "alice@wonderland.io", clientID: clientID, clientSecret: clientSecret, @@ -257,57 +255,54 @@ func TestProxySocketsAdminFacade(t *testing.T) { ControllerUUID: uuid.NewString(), }, nil }, - AuditLog: func(*dbmodel.AuditLogEntry) {}, - JIMM: &mockJIMM{ - authenticator: authenticator, - }, + AuditLog: func(*dbmodel.AuditLogEntry) {}, + LoginService: loginSvc, AuthenticatedIdentityID: test.authenticateEntityID, } - go rpc.ProxySockets(ctx, helpers) - + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + err = rpc.ProxySockets(ctx, helpers) + c.Assert(err, qt.ErrorMatches, "Context cancelled") + }() data, err := json.Marshal(test.messageToSend) c.Assert(err, qt.IsNil) - select { - case clientWebsocket.read <- data: - default: - c.Fatal("failed to send message") - } + clientWebsocket.read <- data if test.expectedClientResponse != nil { select { case data := <-clientWebsocket.write: c.Assert(string(data), qt.JSONEquals, test.expectedClientResponse) - case <-time.Tick(10 * time.Minute): - c.Fatal("time out waiting for response") + case <-time.Tick(2 * time.Second): + c.Fatal("timed out waiting for response") } } if test.expectedControllerMessage != nil { select { case data := <-controllerWebsocket.write: c.Assert(string(data), qt.JSONEquals, test.expectedControllerMessage) - case <-time.Tick(10 * time.Minute): - c.Fatal("time out waiting for response") + case <-time.Tick(2 * time.Second): + c.Fatal("timed out waiting for response") } } + cancelFunc() + wg.Wait() + t.Logf("completed test %s", t.Name()) }) } } -type mockOAuthAuthenticator struct { - jimm.OAuthAuthenticator - - err error - +type mockLoginService struct { + err error email string clientID string clientSecret string - - updatedEmail string } -func (m *mockOAuthAuthenticator) Device(ctx context.Context) (*oauth2.DeviceAuthResponse, error) { - if m.err != nil { - return nil, m.err +func (j *mockLoginService) LoginDevice(ctx context.Context) (*oauth2.DeviceAuthResponse, error) { + if j.err != nil { + return nil, j.err } return &oauth2.DeviceAuthResponse{ DeviceCode: "test-device-code", @@ -318,90 +313,48 @@ func (m *mockOAuthAuthenticator) Device(ctx context.Context) (*oauth2.DeviceAuth Interval: int64(time.Minute.Seconds()), }, nil } - -func (m *mockOAuthAuthenticator) DeviceAccessToken(ctx context.Context, res *oauth2.DeviceAuthResponse) (*oauth2.Token, error) { - if m.err != nil { - return nil, m.err +func (j *mockLoginService) GetDeviceSessionToken(ctx context.Context, deviceOAuthResponse *oauth2.DeviceAuthResponse) (string, error) { + if j.err != nil { + return "", j.err } - return &oauth2.Token{}, nil + return "test session token", nil } - -func (m *mockOAuthAuthenticator) ExtractAndVerifyIDToken(ctx context.Context, oauth2Token *oauth2.Token) (*oidc.IDToken, error) { - if m.err != nil { - return nil, m.err +func (j *mockLoginService) LoginClientCredentials(ctx context.Context, clientID string, clientSecret string) (*openfga.User, error) { + if j.err != nil { + return nil, j.err } - return &oidc.IDToken{}, nil -} - -func (m *mockOAuthAuthenticator) Email(idToken *oidc.IDToken) (string, error) { - if m.err != nil { - return "", m.err + if clientID != j.clientID || clientSecret != j.clientSecret { + return nil, errors.E("invalid client credentials") } - if m.email != "" { - return m.email, nil + clientIdWithDomain, err := jimmnames.EnsureValidServiceAccountId(clientID) + if err != nil { + return nil, errors.E("invalid client credential ID") } - return "", errors.E(errors.CodeNotFound) -} - -func (m *mockOAuthAuthenticator) UpdateIdentity(ctx context.Context, email string, token *oauth2.Token) error { - if m.err != nil { - return m.err + identity, err := dbmodel.NewIdentity(clientIdWithDomain) + if err != nil { + return nil, err } - m.updatedEmail = email - return nil + return openfga.NewUser(identity, nil), nil } - -func (m *mockOAuthAuthenticator) VerifyClientCredentials(ctx context.Context, clientID string, clientSecret string) error { - if m.err != nil { - return m.err +func (j *mockLoginService) LoginWithSessionToken(ctx context.Context, sessionToken string) (*openfga.User, error) { + if j.err != nil { + return nil, j.err } - if clientID == m.clientID && clientSecret == m.clientSecret { - return nil + identity, err := dbmodel.NewIdentity(j.email) + if err != nil { + return nil, err } - return errors.E(errors.CodeUnauthorized) + return openfga.NewUser(identity, nil), nil } - -func (m *mockOAuthAuthenticator) MintSessionToken(email string) (string, error) { - if m.err != nil { - return "", m.err +func (j *mockLoginService) LoginWithSessionCookie(ctx context.Context, identityID string) (*openfga.User, error) { + if j.err != nil { + return nil, j.err } - return "test session token", nil -} - -func (m *mockOAuthAuthenticator) VerifySessionToken(token string) (jwt.Token, error) { - if m.err != nil { - return nil, m.err - } - t := jwt.New() - t.Set(jwt.SubjectKey, m.email) - return t, nil -} - -type mockJIMM struct { - authenticator *mockOAuthAuthenticator -} - -func (j *mockJIMM) OAuthAuthenticationService() jimm.OAuthAuthenticator { - return j.authenticator -} - -func (j *mockJIMM) GetUser(ctx context.Context, email string) (*openfga.User, error) { - identity, err := dbmodel.NewIdentity(email) + identity, err := dbmodel.NewIdentity(j.email) if err != nil { return nil, err } - return openfga.NewUser( - identity, - nil, - ), nil -} - -func (j *mockJIMM) UpdateUserLastLogin(ctx context.Context, identifier string) error { - return nil -} - -func (j *mockJIMM) GetCredentialStore() credentials.CredentialStore { - return jimmtest.NewInMemoryCredentialStore() + return openfga.NewUser(identity, nil), nil } func newMockWebsocketConnection(capacity int) *mockWebsocketConnection { @@ -414,6 +367,7 @@ func newMockWebsocketConnection(capacity int) *mockWebsocketConnection { type mockWebsocketConnection struct { read chan []byte write chan []byte + once sync.Once } func (w *mockWebsocketConnection) ReadJSON(v interface{}) error { @@ -433,7 +387,7 @@ func (w *mockWebsocketConnection) WriteJSON(v interface{}) error { } func (w *mockWebsocketConnection) Close() error { - close(w.read) + w.once.Do(func() { close(w.read) }) return nil } diff --git a/internal/rpc/rpc.go b/internal/rpc/rpc.go index f86733d08..4090942a3 100644 --- a/internal/rpc/rpc.go +++ b/internal/rpc/rpc.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. // Package rpc implements the juju RPC protocol. The main difference // between this implementation and the implementation in diff --git a/internal/servermon/monitoring.go b/internal/servermon/monitoring.go index eec37d789..64be10227 100644 --- a/internal/servermon/monitoring.go +++ b/internal/servermon/monitoring.go @@ -1,4 +1,4 @@ -// Copyright 2016 Canonical Ltd. +// Copyright 2024 Canonical. // The servermon package is used to update statistics used // for monitoring the API server. @@ -122,13 +122,13 @@ var ( ModelCount = promauto.NewGaugeVec(prometheus.GaugeOpts{ Namespace: "jimm", Subsystem: "system", - Name: "model_count", + Name: "model", Help: "The number of models managed per controller attached to JIMM.", }, []string{"controller"}) ControllerCount = promauto.NewGauge(prometheus.GaugeOpts{ Namespace: "jimm", Subsystem: "system", - Name: "controller_count", + Name: "controller", Help: "The number of controllers managed by JIMM.", }) ) diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 4652cc3a8..f277a48b9 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -1,14 +1,23 @@ +// Copyright 2024 Canonical. package utils import ( + "context" "crypto/rand" "encoding/hex" + + "github.com/juju/zaputil/zapctx" + "go.uber.org/zap" ) // NewConversationID generates a unique ID that is used for the // lifetime of a websocket connection. func NewConversationID() string { buf := make([]byte, 8) - rand.Read(buf) // Can't fail + _, err := rand.Read(buf) + if err != nil { + zapctx.Error(context.Background(), "failed to generate rand", zap.Error(err)) + + } return hex.EncodeToString(buf) } diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go index 20ae46ed0..f17c6f8c9 100644 --- a/internal/utils/utils_test.go +++ b/internal/utils/utils_test.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package utils_test import ( diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 5455c5d36..9406f433f 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package vault @@ -29,11 +29,10 @@ const ( ) const ( - jwksKey = "jwks" - jwksExpiryKey = "jwks-expiry" - jwksPrivateKey = "jwks-private" - oAuthSecretKey = "oauth-secret" - oAuthSessionStoreSecretKey = "oauth-session-store-secret" + jwksKey = "jwks" + jwksExpiryKey = "jwks-expiry" + jwksPrivateKey = "jwks-private" + oAuthSecretKey = "oauth-secret" ) // A VaultStore stores cloud credential attributes and @@ -220,10 +219,15 @@ func (s *VaultStore) CleanupJWKS(ctx context.Context) (err error) { if err != nil { return errors.E(op, err) } - // Vault does not return errors on deletion requests where - // the secret does not exist. As such we just return the last known error. - client.KVv2(s.KVPath).Delete(ctx, s.getJWKSExpiryPath()) - client.KVv2(s.KVPath).Delete(ctx, s.getJWKSPath()) + + if err = client.KVv2(s.KVPath).Delete(ctx, s.getJWKSExpiryPath()); err != nil { + return errors.E(op, err) + } + + if err = client.KVv2(s.KVPath).Delete(ctx, s.getJWKSPath()); err != nil { + return errors.E(op, err) + } + if err = client.KVv2(s.KVPath).Delete(ctx, s.getJWKSPrivateKeyPath()); err != nil { return errors.E(op, err) } diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go index fd8f58995..dffb4976e 100644 --- a/internal/vault/vault_test.go +++ b/internal/vault/vault_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. package vault_test diff --git a/internal/wellknownapi/api.go b/internal/wellknownapi/api.go index 62e008819..46396d656 100644 --- a/internal/wellknownapi/api.go +++ b/internal/wellknownapi/api.go @@ -1,4 +1,4 @@ -// Copyright 2023 canonical. +// Copyright 2024 Canonical. package wellknownapi import ( @@ -7,12 +7,13 @@ import ( "net/http" "time" - "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/jimm/credentials" "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/juju/zaputil/zapctx" "go.uber.org/zap" + + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/jimm/credentials" ) // WellKnownHandler holds the grouped router to be mounted and diff --git a/internal/wellknownapi/api_test.go b/internal/wellknownapi/api_test.go index 9cb0b3cb3..ddd206864 100644 --- a/internal/wellknownapi/api_test.go +++ b/internal/wellknownapi/api_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 canonical. +// Copyright 2024 Canonical. package wellknownapi_test import ( @@ -11,12 +11,13 @@ import ( "testing" "time" + qt "github.com/frankban/quicktest" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/canonical/jimm/v3/internal/errors" "github.com/canonical/jimm/v3/internal/jimmtest" "github.com/canonical/jimm/v3/internal/vault" "github.com/canonical/jimm/v3/internal/wellknownapi" - qt "github.com/frankban/quicktest" - "github.com/lestrrat-go/jwx/v2/jwk" ) func newStore(t testing.TB) *vault.VaultStore { @@ -72,6 +73,7 @@ func TestWellknownAPIJWKSJSONHandles404(t *testing.T) { rr := setupHandlerAndRecorder(c, "/jwks.json", store) resp := rr.Result() + defer resp.Body.Close() code := rr.Code b, err := io.ReadAll(resp.Body) c.Assert(err, qt.IsNil) @@ -100,6 +102,7 @@ func TestWellknownAPIJWKSJSONHandles500(t *testing.T) { rr := setupHandlerAndRecorder(c, "/jwks.json", store) resp := rr.Result() + defer resp.Body.Close() code := rr.Code b, err := io.ReadAll(resp.Body) @@ -135,6 +138,7 @@ func TestWellknownAPIJWKSJSONHandles200(t *testing.T) { rr := setupHandlerAndRecorder(c, "/jwks.json", store) resp := rr.Result() + defer resp.Body.Close() code := rr.Code b, err := io.ReadAll(resp.Body) diff --git a/local/README.md b/local/README.md index 37f77aad6..d14463a1e 100644 --- a/local/README.md +++ b/local/README.md @@ -13,30 +13,20 @@ used for integration testing within the JIMM test suite. The service is started using Docker Compose, the following services should be started: - JIMM (only started in the dev profile) +- Traefik (only started in the dev profile) - Vault - Postgres - OpenFGA -- Traefik -> Any changes made inside the repo will automatically restart the JIMM server via a volume mount. So there's no need -to re-run the compose continuously, but note, if you do bring the compose down, remove the volumes otherwise -vault will not behave correctly, this can be done via `docker compose down -v` - -Now please checkout the [Authentication Steps](#authentication-steps) to authenticate postman for local testing & Q/A. - -# Q/A Using Postman -#### Setup -1. Run `make get-local-auth` -2. Head to postman and follow the instructions given by get-local-auth. -#### Facades in Postman -You will see JIMM's controller WS API broken up into separate WS requests. -This is intentional. -Inside of each WS request will be a set of `saved messages` (on the right-hand side), these are the calls to facades for the given API under that request. - -The `request name` represents the literal WS endpoint, i.e., `API = /api`. - -> Remember to run the `Login` message when spinning up a new WS connection, otherwise you will not be able to send subsequent calls to this WS. +Some notes on the setup: +- Local images are created in the repo's `/local/` folder where any init scripts are defined for each service using the service's upstream docker image. +- The docker compose has a base at `compose-common.yaml` for common elements to reduce duplication. +- The compose has 2 additional profiles (dev and test). + - Starting the compose with no profile will spin up the necessary components for testing. + - The dev profile will start JIMM in a container using [air](https://github.com/air-verse/air), a tool for auto-reloading Go code when the source changes. + - The test profile will start JIMM by pulling a version of the JIMM image from a container registry, useful in integration tests. +> Any changes made inside the repo will automatically restart the JIMM server via a volume mount + air. So there's no need to re-run the compose continuously. # Q/A Using jimmctl @@ -47,7 +37,7 @@ The `request name` represents the literal WS endpoint, i.e., `API = /api`. 1. The following commands might need to be run to work around an [LXC networking issue](https://github.com/docker/for-linux/issues/103#issuecomment-383607773): `sudo iptables -F FORWARD && sudo iptables -P FORWARD ACCEPT`. -2. Install Juju: `sudo snap install juju --channel=3.5/stable` (minimum Juju version is `3.5`). +2. Install Juju: `sudo snap install juju --channel=3.5/stable` (minimum required Juju version is `3.5`). 3. Install JQ: `sudo snap install jq`. ## All-In-One scripts diff --git a/local/init.sql b/local/init.sql deleted file mode 100644 index 978210f4a..000000000 --- a/local/init.sql +++ /dev/null @@ -1,21 +0,0 @@ - -/* Setup kv store path for postgres backend */ -CREATE TABLE vault_kv_store ( - parent_path TEXT COLLATE "C" NOT NULL, - path TEXT COLLATE "C", - key TEXT COLLATE "C", - value BYTEA, - CONSTRAINT pkey PRIMARY KEY (path, key) -); - -/* Set index for kv parent */ -CREATE INDEX parent_path_idx ON vault_kv_store (parent_path); - -/* Setup HA locks, so we can emulate a production environment locally */ -CREATE TABLE vault_ha_locks ( - ha_key TEXT COLLATE "C" NOT NULL, - ha_identity TEXT COLLATE "C" NOT NULL, - ha_value TEXT COLLATE "C", - valid_until TIMESTAMP WITH TIME ZONE NOT NULL, - CONSTRAINT ha_key PRIMARY KEY (ha_key) -); diff --git a/local/jimm/add-controller.sh b/local/jimm/add-controller.sh index 23f89aca3..0600c0ac8 100755 --- a/local/jimm/add-controller.sh +++ b/local/jimm/add-controller.sh @@ -20,16 +20,27 @@ echo "JIMM controller name is: $JIMM_CONTROLLER_NAME" echo "Target controller name is: $CONTROLLER_NAME" echo "Target controller path is: $CONTROLLER_YAML_PATH" echo -echo "Building jimmctl..." -# Build jimmctl so we may add a controller. -go build ./cmd/jimmctl -echo "Built." -echo +which jimmctl +jimmctlAvailable=$? +if [ $jimmctlAvailable -ne 0 ]; then + echo "Building jimmctl..." + # Build jimmctl so we may add a controller. + go build ./cmd/jimmctl + echo "Built jimmctl." + echo +else + echo "jimmctl available, skipping build" +fi +if which jimmctl | grep -q 'snap'; then + CONTROLLER_YAML_PATH="$HOME/snap/jimmctl/common/$CONTROLLER_YAML_PATH" + echo "jimmctl is installed as a snap" + echo "placing controller info file at $CONTROLLER_YAML_PATH" +fi echo "Switching juju controller to $JIMM_CONTROLLER_NAME" juju switch "$JIMM_CONTROLLER_NAME" echo echo "Retrieving controller info for $CONTROLLER_NAME" -./jimmctl controller-info --local "$CONTROLLER_NAME" "$CONTROLLER_YAML_PATH" --tls-hostname juju-apiserver +jimmctl controller-info --local "$CONTROLLER_NAME" "$CONTROLLER_YAML_PATH" --tls-hostname juju-apiserver if [[ -f "$CONTROLLER_YAML_PATH" ]]; then echo "Controller info retrieved." else @@ -38,7 +49,7 @@ else fi echo echo "Adding controller from path: $CONTROLLER_YAML_PATH" -./jimmctl add-controller "$CONTROLLER_YAML_PATH" +jimmctl add-controller "$CONTROLLER_YAML_PATH" echo echo "Updating cloud credentials for: $JIMM_CONTROLLER_NAME, from client credential: $CLIENT_CREDENTIAL_NAME" juju update-credentials "$CLIENT_CREDENTIAL_NAME" --controller "$JIMM_CONTROLLER_NAME" diff --git a/local/jimm/setup-cli-auth.sh b/local/jimm/setup-cli-auth.sh new file mode 100755 index 000000000..57464c3d7 --- /dev/null +++ b/local/jimm/setup-cli-auth.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# This script is used to setup a Juju CLI to be authenticated with JIMM without going through login. +# This is particularly useful in headless environments like CI/CD. + +set -eux + +# Note that we are working around the fact that yq is a snap and doesn't have permission to hidden folders due to snap confinement. +cat ~/.local/share/juju/accounts.yaml | yq '.controllers += {"jimm":{"type": "oauth2-device", "user": "jimm-test@canonical.com", "access-token": strenv(JWT)}}' | cat > temp-accounts.yaml && mv temp-accounts.yaml ~/.local/share/juju/accounts.yaml +cat ~/.local/share/juju/controllers.yaml | yq '.controllers += {"jimm":{"uuid": "3217dbc9-8ea9-4381-9e97-01eab0b3f6bb", "api-endpoints": ["jimm.localhost:443"]}}' | cat > temp-controllers.yaml && mv temp-controllers.yaml ~/.local/share/juju/controllers.yaml diff --git a/local/jimm/setup-controller.sh b/local/jimm/setup-controller.sh index 4c1a7a181..dca63e6c7 100755 --- a/local/jimm/setup-controller.sh +++ b/local/jimm/setup-controller.sh @@ -4,12 +4,7 @@ # It will bootstrap a Juju controller and configure the necessary config to enable the controller # to communicate with the docker compose -CLOUDINIT_FILE="cloudinit.temp.yaml" -function finish { - rm "$CLOUDINIT_FILE" -} -trap finish EXIT - +CLOUDINIT_FILE=${CLOUDINIT_FILE:-"cloudinit.temp.yaml"} CONTROLLER_NAME="${CONTROLLER_NAME:-qa-lxd}" CLOUDINIT_TEMPLATE=$'cloudinit-userdata: | preruncmd: @@ -18,7 +13,16 @@ CLOUDINIT_TEMPLATE=$'cloudinit-userdata: | trusted: - |\n%s' +# shellcheck disable=SC2059 +# We are using the variable as the printf template printf "$CLOUDINIT_TEMPLATE" "$(lxc network get lxdbr0 ipv4.address | cut -f1 -d/)" "$(cat local/traefik/certs/ca.crt | sed -e 's/^/ /')" > "${CLOUDINIT_FILE}" +echo "created cloud-init file" + +if [ "${SKIP_BOOTSTRAP:-false}" == true ]; then + echo "skipping controller bootstrap" + exit 0 +fi echo "Bootstrapping controller" juju bootstrap lxd "${CONTROLLER_NAME}" --config "${CLOUDINIT_FILE}" --config login-token-refresh-url=https://jimm.localhost/.well-known/jwks.json +rm "$CLOUDINIT_FILE" diff --git a/local/jimm/setup-service-account.sh b/local/jimm/setup-service-account.sh new file mode 100755 index 000000000..b18229a0c --- /dev/null +++ b/local/jimm/setup-service-account.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# This script is used to setup a service account by adding a set of cloud-credentials. +# Default values below assume a lxd controller is added to JIMM. + +set -eux + +SERVICE_ACCOUNT_ID="${SERVICE_ACCOUNT_ID:-test-client-id}" +CLOUD="${CLOUD:-localhost}" +CREDENTIAL_NAME="${CREDENTIAL_NAME:-localhost}" + +juju add-service-account "$SERVICE_ACCOUNT_ID" +juju update-service-account-credential "$SERVICE_ACCOUNT_ID" "$CLOUD" "$CREDENTIAL_NAME" diff --git a/local/openfga/Dockerfile b/local/openfga/Dockerfile index e2c9b06a7..7bc5c9b72 100644 --- a/local/openfga/Dockerfile +++ b/local/openfga/Dockerfile @@ -1,9 +1,25 @@ # syntax=docker/dockerfile:1.3.1 FROM ubuntu:20.04 AS build -RUN apt-get -qq update && apt-get -qq install -y ca-certificates curl + +# Install some tools necessary for health checks and setup. +RUN apt-get -qq update && apt-get -qq install -y ca-certificates curl wget postgresql-client + EXPOSE 8081 EXPOSE 8080 + WORKDIR /app + +# Copy OpenFGA binaries from upstream image COPY --from=openfga/openfga:v1.2.0 /openfga /app/openfga COPY --from=openfga/openfga:v1.2.0 /assets /app/assets -ENTRYPOINT ["/app/openfga"] + +COPY entrypoint.sh /app/entrypoint.sh + +ENTRYPOINT [ "/app/entrypoint.sh" ] + +HEALTHCHECK \ + --start-period=5s \ + --interval=1s \ + --timeout=5s \ + --retries=10 \ + CMD [ "curl", "http://0.0.0.0:8080/healthz" ] diff --git a/local/openfga/authorisation_model.json b/local/openfga/authorisation_model.json deleted file mode 120000 index 97998ba1b..000000000 --- a/local/openfga/authorisation_model.json +++ /dev/null @@ -1 +0,0 @@ -../../openfga/authorisation_model.json \ No newline at end of file diff --git a/local/openfga/entrypoint.sh b/local/openfga/entrypoint.sh new file mode 100755 index 000000000..b27400c80 --- /dev/null +++ b/local/openfga/entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/sh + +# This script starts the OpenFGA server, migrates the associated database and applies JIMM's auth model. +# It also manually edits the authorization_model_id to a hardcoded value for easier testing. +# Note that this script expects an authorisation_model.json file to be present. We provide that file +# by mounting the file from the host rather than putting it into the Docker container to avoid duplication. + +set -e + +# Migrate the database +./openfga migrate --datastore-engine postgres --datastore-uri "$OPENFGA_DATASTORE_URI" + +./openfga run & +sleep 3 + +# Cleanup old auth model from previous starts +psql -Atx "$OPENFGA_DATASTORE_URI" -c "DELETE FROM authorization_model;" +# Adds the auth model and updates its authorisation model id to be the expected hard-coded id such that our local JIMM can utilise it for queries. +wget -q -O - --header 'Content-Type: application/json' --header 'Authorization: Bearer jimm' --post-file authorisation_model.json localhost:8080/stores/01GP1254CHWJC1MNGVB0WDG1T0/authorization-models +psql -Atx "$OPENFGA_DATASTORE_URI" -c "INSERT INTO store (id,name,created_at,updated_at) VALUES ('01GP1254CHWJC1MNGVB0WDG1T0','jimm',NOW(),NOW()) ON CONFLICT DO NOTHING;" +psql -Atx "$OPENFGA_DATASTORE_URI" -c "UPDATE authorization_model SET authorization_model_id = '01GP1EC038KHGB6JJ2XXXXCXKB' WHERE store = '01GP1254CHWJC1MNGVB0WDG1T0';" + +# Handle exit signals +trap 'kill %1' TERM ; wait diff --git a/local/seed_db/main.go b/local/seed_db/main.go index a9355d3ed..b2403f554 100644 --- a/local/seed_db/main.go +++ b/local/seed_db/main.go @@ -1,3 +1,4 @@ +// Copyright 2024 Canonical. package main import ( @@ -7,15 +8,16 @@ import ( "os" "time" - "github.com/canonical/jimm/v3/internal/db" - "github.com/canonical/jimm/v3/internal/dbmodel" - "github.com/canonical/jimm/v3/internal/logger" petname "github.com/dustinkirkland/golang-petname" "github.com/google/uuid" "github.com/juju/juju/core/crossmodel" "github.com/juju/juju/state" "gorm.io/driver/postgres" "gorm.io/gorm" + + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/logger" ) // A simple script to seed a local database for schema testing. @@ -36,7 +38,7 @@ func main() { DB: gdb, } - db.Migrate(ctx, false) + err = db.Migrate(ctx, false) if err != nil { fmt.Println("failed to migrate to db ", err) os.Exit(1) diff --git a/local/vault/Dockerfile b/local/vault/Dockerfile new file mode 100644 index 000000000..90cb372c1 --- /dev/null +++ b/local/vault/Dockerfile @@ -0,0 +1,20 @@ +FROM hashicorp/vault:latest + +# Add jq to make scripting the calls a bit easier +# ref: https://stedolan.github.io/jq/ +RUN apk add --no-cache bash jq + +# Add our policy and entrypoint +COPY policy.hcl /vault/policy.hcl +COPY entrypoint.sh /vault/entrypoint.sh + +EXPOSE 8200 + +ENTRYPOINT [ "/vault/entrypoint.sh" ] + +HEALTHCHECK \ + --start-period=5s \ + --interval=1s \ + --timeout=1s \ + --retries=30 \ + CMD [ "/bin/sh", "-c", "[ -f /tmp/healthy ]" ] diff --git a/local/vault/approle.go b/local/vault/approle.go deleted file mode 100644 index 8e7ad02e9..000000000 --- a/local/vault/approle.go +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright 2024 Canonical Ltd. - -// This package exists to hold files used to authenticate with Vault during tests. -package vault - -import ( - _ "embed" -) - -//go:embed approle.json -var AppRole []byte diff --git a/local/vault/entrypoint.sh b/local/vault/entrypoint.sh new file mode 100755 index 000000000..a6534793e --- /dev/null +++ b/local/vault/entrypoint.sh @@ -0,0 +1,48 @@ +#!/bin/sh + +# Much of the below was lifted from the sample Vault application setup +# in https://github.com/hashicorp/hello-vault-go/tree/main/sample-app + +set -e + +export VAULT_ADDR='http://localhost:8200' +export VAULT_FORMAT='json' + +# Dev mode defaults some addresses, but also enables us +# to have a custom root key & automatically unsealed vault. +vault server -dev & +sleep 5s + +# Authenticate container's local Vault CLI +# ref: https://www.vaultproject.io/docs/commands/login +vault login -no-print "${VAULT_DEV_ROOT_TOKEN_ID}" + +# AppRole auth is what we use in JIMM, an awesome tutorial +# on how this is setup can be found below. +# HOW-TO: https://developer.hashicorp.com/vault/docs/auth/approle +# AND: +# https://developer.hashicorp.com/vault/tutorials/auth-methods/approle + +echo "Enabling AppRole auth" +vault auth enable approle + +echo "Creating access policy to JIMM stores" +vault policy write jimm-app /vault/policy.hcl + +echo "Creating jimm-app AppRole" +vault write auth/approle/role/jimm-app policies=jimm-app + +# Set fixed role ID and secret ID to simplify testing +vault write auth/approle/role/jimm-app/role-id role_id="test-role-id" +vault write auth/approle/role/jimm-app/custom-secret-id secret_id="test-secret-id" + +# Enable the KV at the defined policy path +echo "Enabling KV at policy path /jimm-kv" +echo "/jimm-kv accessible by policy jimm-app" +vault secrets enable -version=2 -path /jimm-kv kv + +# This container is now healthy +touch /tmp/healthy + +# Handle exit signals +trap 'kill %1' TERM ; wait diff --git a/local/vault/init.sh b/local/vault/init.sh deleted file mode 100755 index 986e5d6d2..000000000 --- a/local/vault/init.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/sh - -# Grab JQ for ease of use. -ARCH=$(arch | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) -wget -O jq https://github.com/jqlang/jq/releases/download/jq-1.7/jq-linux-$ARCH -chmod +x ./jq -cp jq /usr/bin - -# Dev mode defaults some addresses, but also enables us -# to have a custom root key & automatically unsealed vault. -vault server -dev -config=/vault/config/vault.hcl & -sleep 2 - -# Login. -echo "token" | vault login - - -# Set address for local client. -export VAULT_ADDR="http://localhost:8200" -# Makes reading easier. -export VAULT_FORMAT=json - -# AppRole auth is what we use in JIMM, an awesome tutorial -# on how this is setup can be found below. -# HOW-TO: https://developer.hashicorp.com/vault/docs/auth/approle -# AND: -# https://developer.hashicorp.com/vault/tutorials/auth-methods/approle -echo "Enabling AppRole auth" -vault auth enable approle - -echo "Creating access policy to JIMM stores" -vault policy write jimm-app /vault/policy.hcl - -echo "Creating jimm-app AppRole" -vault write auth/approle/role/jimm-app \ - policies=jimm-app - - -# We mimic the normal flow just for reference. Ultimately we passed to secret itself. -# This is because our flow looks at a raw unwrapped secret, rather than carefully -# extracting the role id & secret id from the unwrapped token in cubbyhole. -JIMM_ROLE_ID=$(vault read auth/approle/role/jimm-app/role-id | jq -r '.data.role_id') -echo "AppRole created, role ID is: $JIMM_ROLE_ID" -JIMM_SECRET_WRAPPED=$(vault write -wrap-ttl=10h -force auth/approle/role/jimm-app/secret-id | jq -r '.wrap_info.token') -echo "SecretID applied & wrapped in cubbyhole for 10h, token is: $JIMM_SECRET_WRAPPED" - -# Enable the KV at the defined policy path -echo "Enabling KV at policy path /jimm-kv" -echo "/jimm-kv accessible by policy jimm-app" -vault secrets enable -version=2 -path /jimm-kv kv -echo "Creating approle auth file." -VAULT_TOKEN=$JIMM_SECRET_WRAPPED vault unwrap > /vault/approle_tmp.yaml -echo "$JIMM_ROLE_ID" > /vault/roleid.txt - -jq ".data.role_id = \"$JIMM_ROLE_ID\"" /vault/approle_tmp.yaml > /vault/approle.json -role_id=$(cat /vault/approle.json | jq -r ".data.role_id") -role_secret_id=$(cat /vault/approle.json | jq -r ".data.secret_id") -echo "VAULT_ROLE_ID=$role_id" > /vault/vault.env -echo "VAULT_ROLE_SECRET_ID=$role_secret_id" >> /vault/vault.env -wait - diff --git a/local/vault/vault.hcl b/local/vault/vault.hcl deleted file mode 100644 index be54919f2..000000000 --- a/local/vault/vault.hcl +++ /dev/null @@ -1,8 +0,0 @@ - -storage "postgresql" { - connection_url = "postgres://jimm:jimm@db:5432/jimm" -} - -# Reachable here: http://localhost:8200/ui/ -ui = true - diff --git a/openfga/auth_model.go b/openfga/auth_model.go index 7b25e2c6e..543e3c8aa 100644 --- a/openfga/auth_model.go +++ b/openfga/auth_model.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. // This package exists to hold JIMM's OpenFGA authorisation model. // It embeds the auth model and provides it for tests. diff --git a/pkg/api/client.go b/pkg/api/client.go index 33e157bba..a736e515e 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package api diff --git a/pkg/api/params/errors.go b/pkg/api/params/errors.go index 773ea23fd..dd46638f9 100644 --- a/pkg/api/params/errors.go +++ b/pkg/api/params/errors.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package params diff --git a/pkg/api/params/params.go b/pkg/api/params/params.go index 0ee6869b4..4f917f30f 100644 --- a/pkg/api/params/params.go +++ b/pkg/api/params/params.go @@ -1,4 +1,4 @@ -// Copyright 2020 Canonical Ltd. +// Copyright 2024 Canonical. package params @@ -219,7 +219,7 @@ type FindAuditEventsRequest struct { // A ListControllersResponse is the response that is sent in a // ListControllers method. type ListControllersResponse struct { - Controllers []ControllerInfo `json:"controllers",yaml:"controllers"` + Controllers []ControllerInfo `json:"controllers" yaml:"controllers"` } // A RemoveControllerRequest is the request that is sent in a @@ -307,15 +307,15 @@ type ListGroupsRequest struct { // Group holds the details of a group currently residing in JIMM. type Group struct { - UUID string `json:"uuid",yaml:"uuid"` - Name string `json:"name",yaml:"name"` - CreatedAt string `json:"created_at",yaml:"created_at"` - UpdatedAt string `json:"updated_at",yaml:"updated_at"` + UUID string `json:"uuid" yaml:"uuid"` + Name string `json:"name" yaml:"name"` + CreatedAt string `json:"created_at" yaml:"created_at"` + UpdatedAt string `json:"updated_at" yaml:"updated_at"` } // ListGroupResponse returns the group tuples currently residing within OpenFGA. type ListGroupResponse struct { - Groups []Group `json:"name",yaml:"name"` + Groups []Group `json:"name" yaml:"name"` } // RelationshipTuple represents a OpenFGA Tuple. @@ -348,7 +348,7 @@ type CheckRelationRequest struct { // CheckRelationResponse simple responds with an object containing a boolean of 'allowed' or not // when a check for access is requested. type CheckRelationResponse struct { - Allowed bool `json:"allowed",yaml:"allowed"` + Allowed bool `json:"allowed" yaml:"allowed"` } // ListRelationshipTuplesRequests holds the request information to list tuples. @@ -378,8 +378,8 @@ type CrossModelQueryRequest struct { // - Results - A map of each iterated JQ output result. The key for this map is the model UUID. // - Errors - A map of each iterated JQ *or* Status call error. The key for this map is the model UUID. type CrossModelQueryResponse struct { - Results map[string][]any `json:"results",yaml:"results"` - Errors map[string][]string `json:"errors",yaml:"errors"` + Results map[string][]any `json:"results" yaml:"results"` + Errors map[string][]string `json:"errors" yaml:"errors"` } // PurgeLogsRequest is the request used to purge logs. @@ -415,9 +415,9 @@ type LoginDeviceResponse struct { // VerificationURI holds the URI that the user must navigate to // when entering their "user-code" to consent to this authorisation // request. - VerificationURI string `json:"verification-uri",yaml:"verification-uri"` + VerificationURI string `json:"verification-uri" yaml:"verification-uri"` // UserCode holds the one-time use user consent code. - UserCode string `json:"user-code",yaml:"user-code"` + UserCode string `json:"user-code" yaml:"user-code"` } // GetDeviceSessionTokenResponse returns a session token to be used against @@ -427,7 +427,7 @@ type GetDeviceSessionTokenResponse struct { // SessionToken is a base64 encoded JWT capable of authenticating // a user. The JWT contains the users email address in the subject, // and this is used to identify this user. - SessionToken string `json:"session-token",yaml:"session-token"` + SessionToken string `json:"session-token" yaml:"session-token"` } // LoginWithSessionTokenRequest accepts a session token minted by JIMM and logs @@ -494,6 +494,6 @@ type GrantServiceAccountAccess struct { // WhoamiResponse holds the response for a /auth/whoami call. type WhoamiResponse struct { - DisplayName string `json:"display-name",yaml:"display-name"` - Email string `json:"email",yaml:"email"` + DisplayName string `json:"display-name" yaml:"display-name"` + Email string `json:"email" yaml:"email"` } diff --git a/pkg/names/applicationoffer.go b/pkg/names/applicationoffer.go index e4a163063..258854996 100644 --- a/pkg/names/applicationoffer.go +++ b/pkg/names/applicationoffer.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package names diff --git a/pkg/names/group.go b/pkg/names/group.go index ac1d07978..ad9ecde2f 100644 --- a/pkg/names/group.go +++ b/pkg/names/group.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package names diff --git a/pkg/names/group_test.go b/pkg/names/group_test.go index 3846f90d0..10f7f8b46 100644 --- a/pkg/names/group_test.go +++ b/pkg/names/group_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package names_test diff --git a/pkg/names/names.go b/pkg/names/names.go index 84e61ea98..5e0b18c98 100644 --- a/pkg/names/names.go +++ b/pkg/names/names.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package names diff --git a/pkg/names/service_account.go b/pkg/names/service_account.go index ec94e7819..8c1c2616a 100644 --- a/pkg/names/service_account.go +++ b/pkg/names/service_account.go @@ -1,12 +1,12 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package names import ( + "errors" "fmt" "strings" - "github.com/canonical/jimm/v3/internal/errors" "github.com/juju/names/v5" ) @@ -20,6 +20,11 @@ const ( ServiceAccountDomain = "serviceaccount" ) +var ( + // ErrInvalidClientID indicates an invalid client ID error. + ErrInvalidClientID = errors.New("invalid client ID") +) + // Service accounts are an OIDC/OAuth concept which allows for machine<->machine communication. // Service accounts are identified by their client ID. @@ -81,7 +86,7 @@ func EnsureValidServiceAccountId(id string) (string, error) { } if !IsValidServiceAccountId(id) { - return "", errors.E(errors.CodeBadRequest, "invalid client ID") + return "", ErrInvalidClientID } return id, nil } diff --git a/pkg/names/service_account_test.go b/pkg/names/service_account_test.go index b2f0132f2..98880be3e 100644 --- a/pkg/names/service_account_test.go +++ b/pkg/names/service_account_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Canonical Ltd. +// Copyright 2024 Canonical. package names_test diff --git a/scripts/lxd-build-test.sh b/scripts/lxd-build-test.sh deleted file mode 100755 index 11a1d9003..000000000 --- a/scripts/lxd-build-test.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/sh -# lxd-build-test.sh - run JIMM tests in a clean LXD environment - -set -eu - -image=${image:-ubuntu:18.04} -container=${container:-jimm-test-`uuidgen`} -packages="build-essential bzr git make" - -lxc launch -e $image $container -trap "lxc delete --force $container" EXIT - -lxc exec $container -- sh -c 'while [ ! -f /var/lib/cloud/instance/boot-finished ]; do sleep 0.1; done' - -lxc exec --env http_proxy=${http_proxy:-} --env no_proxy=${no_proxy:-} $container -- apt-get update -y -lxc exec --env http_proxy=${http_proxy:-} --env no_proxy=${no_proxy:-} $container -- apt-get install -y $packages -lxc exec $container -- snap set system proxy.http=${http_proxy:-} -lxc exec $container -- snap set system proxy.https=${https_proxy:-${http_proxy:-}} -lxc exec $container -- snap install go --classic -lxc exec $container -- snap install vault -lxc exec $container -- snap install juju-db --devmode -if [ -n "${http_proxy:-}" ]; then - lxc exec \ - --env HOME=/home/ubuntu \ - --cwd /home/ubuntu/ \ - --user 1000 \ - --group 1000 \ - $container -- git config --global http.proxy ${http_proxy:-} -fi - -lxc file push --uid 1000 --gid 1000 --mode 600 ${NETRC:-$HOME/.netrc} $container/home/ubuntu/.netrc -lxc exec --cwd /home/ubuntu/ --user 1000 --group 1000 $container -- mkdir -p /home/ubuntu/src -tar c . | lxc exec --cwd /home/ubuntu/src/ --user 1000 --group 1000 $container -- tar x -lxc exec \ - --env HOME=/home/ubuntu \ - --env http_proxy=${http_proxy:-} \ - --env https_proxy=${https_proxy:-${http_proxy:-}} \ - --env no_proxy=${no_proxy:-} \ - --cwd /home/ubuntu/src/ \ - --user 1000 \ - --group 1000 \ - $container -- go mod download - -if [ -n "${juju_version:-}" ]; then - lxc exec \ - --env HOME=/home/ubuntu \ - --env http_proxy=${http_proxy:-} \ - --env https_proxy=${https_proxy:-${http_proxy:-}} \ - --env no_proxy=${no_proxy:-} \ - --cwd /home/ubuntu/src/ \ - --user 1000 \ - --group 1000 \ - $container -- go get github.com/juju/juju@$juju_version -fi - -lxc exec \ - --env HOME=/home/ubuntu \ - --env http_proxy=${http_proxy:-} \ - --env https_proxy=${https_proxy:-${http_proxy:-}} \ - --env no_proxy=${no_proxy:-} \ - --cwd /home/ubuntu/src/ \ - --user 1000 \ - --group 1000 \ - $container -- make check diff --git a/scripts/lxd-charm-build.sh b/scripts/lxd-charm-build.sh deleted file mode 100755 index 48694127b..000000000 --- a/scripts/lxd-charm-build.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/sh -# lxd-snap-build.sh - build JIMM charm in a clean LXD environment - -set -eu - -charm_name=juju-jimm -image=${image:-ubuntu:20.04} -container=${container:-${charm_name}-charm-`uuidgen`} - -lxd_exec() { - lxc exec \ - --env http_proxy=${http_proxy:-} \ - --env https_proxy=${https_proxy:-${http_proxy:-}} \ - --env no_proxy=${no_proxy:-} \ - $container -- "$@" -} - -lxd_exec_ubuntu() { - lxc exec \ - --env HOME=/home/ubuntu \ - --env http_proxy=${http_proxy:-} \ - --env https_proxy=${https_proxy:-${http_proxy:-}} \ - --env no_proxy=${no_proxy:-} \ - --user 1000 \ - --group 1000 \ - --cwd=${cwd:-/home/ubuntu} \ - $container -- "$@" -} - -lxc launch -e ${image} $container -trap "lxc stop $container" EXIT - -lxd_exec sh -c 'while [ ! -f /var/lib/cloud/instance/boot-finished ]; do sleep 0.1; done' - -lxd_exec apt-get update -q -y -lxd_exec apt-get upgrade -q -y -lxd_exec apt-get install -y build-essential autoconf python-dev-is-python3 -if [ -n "${http_proxy:-}" ]; then - lxd_exec snap set system proxy.http=${http_proxy:-} - lxd_exec snap set system proxy.https=${https_proxy:-${http_proxy:-}} - lxd_exec_ubuntu git config --global http.proxy ${http_proxy:-} -fi -lxd_exec snap install charmcraft --classic -echo "Push .netrc" -lxc file push --uid 1000 --gid 1000 --mode 600 ${NETRC:-$HOME/.netrc} $container/home/ubuntu/.netrc -echo "Create src" -lxd_exec_ubuntu mkdir -p /home/ubuntu/src -echo "Transfer data" -tar c -C `dirname $0`/.. . | cwd=/home/ubuntu/src lxd_exec_ubuntu tar x - - -echo "Charmcraft build" -cwd=/home/ubuntu/src/charms/jimm lxd_exec_ubuntu sudo -E charmcraft pack --verbose --destructive-mode -echo "Find file" -charmfile=`lxd_exec_ubuntu find /home/ubuntu/src/charms/jimm -name "${charm_name}_*.charm"| head -1` -echo "Pull file" -lxc file pull $container$charmfile . diff --git a/scripts/lxd-k8s-charm-build.sh b/scripts/lxd-k8s-charm-build.sh deleted file mode 100755 index a04edeac2..000000000 --- a/scripts/lxd-k8s-charm-build.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/sh -# lxd-snap-build.sh - build JIMM charm in a clean LXD environment - -set -eu - -charm_name=juju-jimm-k8s -image=${image:-ubuntu:20.04} -container=${container:-${charm_name}-charm-`uuidgen`} - -lxd_exec() { - lxc exec \ - --env http_proxy=${http_proxy:-} \ - --env https_proxy=${https_proxy:-${http_proxy:-}} \ - --env no_proxy=${no_proxy:-} \ - $container -- "$@" -} - -lxd_exec_ubuntu() { - lxc exec \ - --env HOME=/home/ubuntu \ - --env http_proxy=${http_proxy:-} \ - --env https_proxy=${https_proxy:-${http_proxy:-}} \ - --env no_proxy=${no_proxy:-} \ - --user 1000 \ - --group 1000 \ - --cwd=${cwd:-/home/ubuntu} \ - $container -- "$@" -} - -lxc launch -e ${image} $container -trap "lxc stop $container" EXIT - -lxd_exec sh -c 'while [ ! -f /var/lib/cloud/instance/boot-finished ]; do sleep 0.1; done' - -lxd_exec apt-get update -q -y -lxd_exec apt-get upgrade -q -y -lxd_exec apt-get install -y build-essential autoconf python-dev -if [ -n "${http_proxy:-}" ]; then - lxd_exec snap set system proxy.http=${http_proxy:-} - lxd_exec snap set system proxy.https=${https_proxy:-${http_proxy:-}} - lxd_exec_ubuntu git config --global http.proxy ${http_proxy:-} -fi -lxd_exec snap install charmcraft --classic -echo "Push .netrc" -lxc file push --uid 1000 --gid 1000 --mode 600 ${NETRC:-$HOME/.netrc} $container/home/ubuntu/.netrc -echo "Create src" -lxd_exec_ubuntu mkdir -p /home/ubuntu/src -echo "Transfer data" -tar c -C `dirname $0`/.. . | cwd=/home/ubuntu/src lxd_exec_ubuntu tar x - -echo "Charmcraft build" -cwd=/home/ubuntu/src/charms/jimm-k8s lxd_exec_ubuntu sudo -E charmcraft pack --verbose --destructive-mode -echo "Find file" -charmfile=`lxd_exec_ubuntu find /home/ubuntu/src/charms/jimm-k8s -name "${charm_name}_*.charm"| head -1` -echo "Pull file" -lxc file pull $container$charmfile . diff --git a/scripts/lxd-release-build.sh b/scripts/lxd-release-build.sh deleted file mode 100644 index ae4ebb066..000000000 --- a/scripts/lxd-release-build.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/sh -# lxd-release-build.sh - build JIMM releases in a clean LXD environment - -set -eu - -image=${image:-ubuntu:20.04} -container=${container:-jimm-release-`uuidgen`} - -lxd_exec() { - lxc exec \ - --env http_proxy=${http_proxy:-} \ - --env https_proxy=${https_proxy:-${http_proxy:-}} \ - --env no_proxy=${no_proxy:-} \ - $container -- "$@" -} - -lxd_exec_ubuntu() { - lxc exec \ - --env HOME=/home/ubuntu \ - --env http_proxy=${http_proxy:-} \ - --env https_proxy=${https_proxy:-${http_proxy:-}} \ - --env no_proxy=${no_proxy:-} \ - --user 1000 \ - --group 1000 \ - --cwd=${cwd:-/home/ubuntu} \ - $container -- "$@" -} - -lxc launch -e ${image} $container -trap "lxc delete --force $container" EXIT - -lxd_exec sh -c 'while [ ! -f /var/lib/cloud/instance/boot-finished ]; do sleep 0.1; done' - -lxd_exec apt-get update -y -lxd_exec apt-get install -y build-essential bzr git make mongodb -if [ -n "${http_proxy:-}" ]; then - lxd_exec snap set system proxy.http=${http_proxy:-} - lxd_exec snap set system proxy.https=${https_proxy:-${http_proxy:-}} - lxd_exec_ubuntu git config --global http.proxy ${http_proxy:-} -fi -lxd_exec snap install go --classic -lxd_exec snap install vault - -lxc file push --uid 1000 --gid 1000 --mode 600 ${NETRC:-$HOME/.netrc} $container/home/ubuntu/.netrc -lxd_exec_ubuntu mkdir -p /home/ubuntu/src -tar c . | cwd=/home/ubuntu/src lxd_exec_ubuntu tar x -cwd=/home/ubuntu/src lxd_exec_ubuntu go mod download - -cwd=/home/ubuntu/src lxd_exec_ubuntu make check -cwd=/home/ubuntu/src lxd_exec_ubuntu make release - -tarfile=`lxd_exec_ubuntu find /home/ubuntu/src -name "jimm-*.tar.xz"| head -1` -lxc file pull $container$tarfile . diff --git a/scripts/lxd-snap-build.sh b/scripts/lxd-snap-build.sh deleted file mode 100755 index 438e3db07..000000000 --- a/scripts/lxd-snap-build.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/sh -# lxd-snap-build.sh - build JIMM snaps in a clean LXD environment - -set -eu - -snap_name=${snap_name:-jimm} -image=${image:-ubuntu:20.04} -container=${container:-${snap_name}-snap} - -lxd_exec() { - lxc exec \ - --env http_proxy=${http_proxy:-} \ - --env https_proxy=${https_proxy:-${http_proxy:-}} \ - --env no_proxy=${no_proxy:-} \ - $container -- "$@" -} - -lxd_exec_ubuntu() { - lxc exec \ - --env HOME=/home/ubuntu \ - --env http_proxy=${http_proxy:-} \ - --env https_proxy=${https_proxy:-${http_proxy:-}} \ - --env no_proxy=${no_proxy:-} \ - --user 1000 \ - --group 1000 \ - --cwd=${cwd:-/home/ubuntu} \ - $container -- "$@" -} - -lxc launch -e ${image} $container -trap "lxc stop $container" EXIT - -lxd_exec sh -c 'while [ ! -f /var/lib/cloud/instance/boot-finished ]; do sleep 0.1; done' - -lxd_exec apt-get update -q -y -lxd_exec apt-get upgrade -q -y -lxd_exec apt-get install build-essential -q -y -if [ -n "${http_proxy:-}" ]; then - lxd_exec snap set system proxy.http=${http_proxy:-} - lxd_exec snap set system proxy.https=${https_proxy:-${http_proxy:-}} - lxd_exec_ubuntu git config --global http.proxy ${http_proxy:-} -fi -lxd_exec snap install snapcraft --classic -lxc file push --uid 1000 --gid 1000 --mode 600 ${NETRC:-$HOME/.netrc} $container/home/ubuntu/.netrc -lxd_exec_ubuntu mkdir -p /home/ubuntu/src -tar c -C `dirname $0`/.. . | cwd=/home/ubuntu/src lxd_exec_ubuntu tar x -target= -if [ -n "${target_arch:-}" ]; then - target="--target-arch ${target_arch}" -fi -cwd=/home/ubuntu/src/snaps/$snap_name lxd_exec_ubuntu snapcraft --destructive-mode $target -snapfile=`lxd_exec_ubuntu find /home/ubuntu/src -name "${snap_name}_*.snap"| head -1` -lxc file pull $container$snapfile . diff --git a/version/default.go b/version/default.go index 55e1dddc9..7ec4fa5f0 100644 --- a/version/default.go +++ b/version/default.go @@ -1,4 +1,4 @@ -// Copyright 2021 Canonical Ltd. +// Copyright 2024 Canonical. // +build !version //go:build !version diff --git a/version/version.go b/version/version.go index c14d1623d..dec1d7375 100644 --- a/version/version.go +++ b/version/version.go @@ -1,4 +1,4 @@ -// Copyright 2015 Canonical Ltd. +// Copyright 2024 Canonical. package version