diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5de7d12c3d..fc46f661cc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,7 +7,9 @@ version: 2 updates: # Dependabot for CI - package-ecosystem: github-actions - directory: / + directories: + - "/" + - "/otdfctl/e2e" schedule: interval: monthly commit-message: @@ -68,6 +70,17 @@ updates: schedule: interval: daily + - package-ecosystem: gomod + directory: "/otdfctl" + commit-message: + prefix: "fix(deps)" + groups: + external: + exclude-patterns: + - "github.com/opentdf/*" + schedule: + interval: daily + - package-ecosystem: gomod directory: "/protocol/go" commit-message: diff --git a/.github/release-please/release-please-config.main.json b/.github/release-please/release-please-config.main.json index 1a532d69fc..438dd5b50b 100644 --- a/.github/release-please/release-please-config.main.json +++ b/.github/release-please/release-please-config.main.json @@ -19,6 +19,15 @@ "lib/identifier": { "component": "lib/identifier" }, + "otdfctl": { + "component": "otdfctl", + "extra-files": [ + { + "type": "generic", + "path": "pkg/config/config.go" + } + ] + }, "protocol/go": { "component": "protocol/go" }, @@ -41,4 +50,4 @@ ] } } -} +} \ No newline at end of file diff --git a/.github/release-please/release-please-config.otdfctl.json b/.github/release-please/release-please-config.otdfctl.json new file mode 100644 index 0000000000..66d64b2622 --- /dev/null +++ b/.github/release-please/release-please-config.otdfctl.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "go", + "versioning": "always-bump-patch", + "separate-pull-requests": true, + "include-component-in-tag": true, + "pull-request-title-pattern": "chore(release): release ${component} ${version}", + "tag-separator": "/", + "packages": { + "otdfctl": { + "component": "otdfctl", + "extra-files": [ + { + "type": "generic", + "path": "pkg/config/config.go" + } + ] + } + } +} diff --git a/.github/release-please/release-please-manifest.json b/.github/release-please/release-please-manifest.json index acf10ef159..18d5ff74c3 100644 --- a/.github/release-please/release-please-manifest.json +++ b/.github/release-please/release-please-manifest.json @@ -3,6 +3,7 @@ "lib/ocrypto": "0.10.0", "lib/flattening": "0.1.3", "lib/identifier": "0.3.0", + "otdfctl": "0.30.0", "protocol/go": "0.25.0", "sdk": "0.15.0", "service": "0.13.0" diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 6ec4dc1442..f8e0babf22 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -33,6 +33,7 @@ jobs: matrix: directory: - examples + - otdfctl - sdk - service - lib/ocrypto @@ -51,6 +52,7 @@ jobs: check-latest: false cache-dependency-path: | examples/go.sum + otdfctl/go.sum protocol/go/go.sum sdk/go.sum service/go.sum @@ -545,23 +547,26 @@ jobs: cukes_platform_report.log retention-days: 1 - # test latest otdfctl CLI 'main' against platform PR branch + # test otdfctl CLI e2e against platform PR branch otdfctl-test: permissions: contents: read name: otdfctl e2e tests runs-on: ubuntu-latest steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false - name: Install GNU parallel run: | sudo apt update sudo apt install -y parallel - - uses: opentdf/platform/test/start-up-with-containers@main + - uses: ./test/start-up-with-containers with: platform-ref: ${{ github.event.pull_request.head.sha || github.sha }} - - uses: opentdf/otdfctl/e2e@main - with: - otdfctl-ref: "main" + provision-policy-fixtures: "false" + - uses: ./otdfctl/e2e env: TESTRAIL_USER: ${{ secrets.TESTRAIL_USER }} TESTRAIL_PASS: ${{ secrets.TESTRAIL_PASS }} diff --git a/.github/workflows/nightly-checks.yaml b/.github/workflows/nightly-checks.yaml index 513eb52879..7822065730 100644 --- a/.github/workflows/nightly-checks.yaml +++ b/.github/workflows/nightly-checks.yaml @@ -27,6 +27,7 @@ jobs: check-latest: false cache-dependency-path: | platform/examples/go.sum + platform/otdfctl/go.sum platform/protocol/go/go.sum platform/sdk/go.sum platform/service/go.sum @@ -60,18 +61,11 @@ jobs: wait-for: 90s working-directory: platform - ######## CHECKOUT/BUILD 'otdfctl' ############# - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - repository: opentdf/otdfctl - ref: main - fetch-depth: 0 - path: otdfctl - persist-credentials: false - - run: go build -o otdfctl - working-directory: otdfctl - - run: cp otdfctl ../platform - working-directory: otdfctl + ######## BUILD 'otdfctl' (now part of platform monorepo) ############# + - run: go build -o otdfctl . + working-directory: platform/otdfctl + - run: cp otdfctl ../ + working-directory: platform/otdfctl ######## RUN TESTS ############# - run: ./.github/scripts/connectivity-test.sh diff --git a/.github/workflows/pr-checks.yaml b/.github/workflows/pr-checks.yaml index 7f55c603ec..00ae548827 100644 --- a/.github/workflows/pr-checks.yaml +++ b/.github/workflows/pr-checks.yaml @@ -46,6 +46,7 @@ jobs: # - main: used for automated releases # - core: related to any core need such as the core service or monorepo # - ci: anything related to ci + # - cli: related to otdfctl # - deps: dependency update # - docs: anything related solely to documentation # - sdk: related to sdk changes in the /sdk directory @@ -56,6 +57,7 @@ jobs: main core ci + cli deps docs sdk diff --git a/.github/workflows/release-otdfctl.yaml b/.github/workflows/release-otdfctl.yaml new file mode 100644 index 0000000000..539e880ee4 --- /dev/null +++ b/.github/workflows/release-otdfctl.yaml @@ -0,0 +1,43 @@ +name: "Build otdfctl CLI Binaries" + +on: + release: + types: [published] + +permissions: {} + +jobs: + build: + if: ${{ startsWith(github.event.release.tag_name, 'otdfctl/') }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 + with: + go-version-file: otdfctl/go.mod + + - name: Extract version from tag + id: version + env: + TAG: ${{ github.event.release.tag_name }} + run: | + VERSION="${TAG#otdfctl/v}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Build cross-platform binaries + working-directory: otdfctl + env: + SEM_VER: ${{ steps.version.outputs.version }} + COMMIT_SHA: ${{ github.sha }} + run: make build + + - name: Upload release artifacts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$RELEASE_TAG" ./otdfctl/output/* diff --git a/.gitignore b/.gitignore index b55a187b95..4b8411c9f6 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,40 @@ traces/ .cache/* # Claude AI files -.claude/ \ No newline at end of file +.claude/ + +# otdfctl specific ignores +# ========================= +otdfctl/bin/.DS_Store +otdfctl/.DS_Store +otdfctl/target/ +otdfctl/.vscode/launch.json +otdfctl/otdfctl.yaml + +# Ignore the binaries +otdfctl/otdfctl +otdfctl/otdfctl.* +otdfctl/otdfctl_testbuild +otdfctl/otdfctl_testbuild.* + +# Test artifacts +otdfctl/creds.json +otdfctl/**/creds.json + +# TestRail-related files +otdfctl/testrail.config.json +otdfctl/testname-to-testrail-id.json +otdfctl/mapping-report.txt +otdfctl/bats-results.tap + +# Hugo +otdfctl/public/ +otdfctl/.hugo_build.lock +otdfctl/output/ + +# Ignore any TDF files created by the CLI +otdfctl/*.tdf + +# Ignore go.cache +otdfctl/.gocache +# ========================= diff --git a/.golangci.yaml b/.golangci.yaml index 19cdbd1217..9513d465d3 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -186,9 +186,31 @@ linters: - linters: - goimport text: http://www.apache.org/licenses/LICENSE-2.0 + # otdfctl: defer refactoring-level lint fixes to follow-up + - path: otdfctl/ + linters: + - contextcheck + text: should pass the context parameter + - path: otdfctl/ + linters: + - revive + text: unused-parameter + - path: otdfctl/ + linters: + - revive + text: unexported-return + - path: otdfctl/ + linters: + - revive + text: var-naming + - path: otdfctl/ + linters: + - nolintlint + text: "exhaustive" paths: - .*\.pb\.go - .*\.pb\.gw.go + - otdfctl/tui/ # excluded during migration, matching original otdfctl lint config - third_party$ - builtin$ - examples$ @@ -204,6 +226,7 @@ formatters: paths: - .*\.pb\.go - .*\.pb\.gw.go + - otdfctl/tui/ - third_party$ - builtin$ - examples$ diff --git a/CODEOWNERS b/CODEOWNERS index 0820929726..7571a0b6b7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -41,6 +41,10 @@ /sdk/ @opentdf/go-sdk @opentdf/architecture /sdk/go.* @opentdf/go-sdk @opentdf/architecture @opentdf/security +## CLI + +/otdfctl/ @opentdf/cli + ## High Security Area CODEOWNERS @opentdf/architecture @opentdf/security diff --git a/Dockerfile b/Dockerfile index 94754d7db1..d33632eb23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ COPY protocol/ protocol/ COPY sdk/ sdk/ COPY lib/ lib/ COPY service/ service/ +COPY otdfctl/ otdfctl/ COPY examples/ examples/ COPY tests-bdd/ tests-bdd/ COPY go.work ./ diff --git a/Makefile b/Makefile index ead4689a47..50cad22e52 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,10 @@ # make # To run all lint checks: `LINT_OPTIONS= make lint` -.PHONY: all build clean connect-wrapper-generate docker-build fix fmt go-lint license lint proto-generate proto-helper-generate proto-lint sdk/sdk test tidy toolcheck +.PHONY: all build clean connect-wrapper-generate docker-build fix fmt go-lint license lint otdfctl/otdfctl proto-generate proto-helper-generate proto-lint sdk/sdk test tidy toolcheck -MODS=protocol/go lib/ocrypto lib/fixtures lib/flattening lib/identifier sdk service examples -HAND_MODS=lib/ocrypto lib/fixtures lib/flattening lib/identifier sdk service examples +MODS=protocol/go lib/ocrypto lib/fixtures lib/flattening lib/identifier sdk service examples otdfctl +HAND_MODS=lib/ocrypto lib/fixtures lib/flattening lib/identifier sdk service examples otdfctl REQUIRED_BUF_VERSION=1.56.0 ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) @@ -115,9 +115,9 @@ bench: clean: for m in $(MODS); do (cd $$m && go clean) || exit 1; done - rm -f opentdf examples/examples + rm -f opentdf examples/examples otdfctl/otdfctl -build: proto-generate connect-wrapper-generate opentdf sdk/sdk examples/examples +build: proto-generate connect-wrapper-generate opentdf sdk/sdk examples/examples otdfctl/otdfctl opentdf: $(shell find service) go build -o opentdf -v service/main.go @@ -128,5 +128,8 @@ sdk/sdk: $(shell find sdk) examples/examples: $(shell find examples) (cd examples && go build -o examples .) +otdfctl/otdfctl: $(shell find otdfctl) + (cd otdfctl && go build -o otdfctl .) + docker-build: build docker build -t opentdf . diff --git a/adr/decisions/2026-03-24-otdfctl-migration.md b/adr/decisions/2026-03-24-otdfctl-migration.md new file mode 100644 index 0000000000..a8ca2015c7 --- /dev/null +++ b/adr/decisions/2026-03-24-otdfctl-migration.md @@ -0,0 +1,26 @@ +## Summary + +We are planning to migrate the `otdfctl` CLI from this standalone repository into the [`opentdf/platform`](https://github.com/opentdf/platform) monorepo. After migration, this repository will be archived and marked read-only. + +## Why + +- otdfctl already depends heavily on platform (SDK, protocol, libs) and uses platform's reusable CI workflows +- Both repos run each other's e2e tests in CI — consolidating eliminates cross-repo coordination overhead +- The platform monorepo already supports per-component releases (service, sdk, libs), so otdfctl can maintain independent release cadence + +## What changes for users + +- **Go module path** will change from `github.com/opentdf/otdfctl` to `github.com/opentdf/platform/otdfctl` +- **Release tags** will change from `v0.X.Y` to `otdfctl/v0.X.Y` +- **This repository** will be archived (read-only) — all existing releases and tags will remain accessible +- A notice will be added to this README pointing to the new location + +## What stays the same + +- The `otdfctl` binary name and CLI interface +- Separate release cadence (not coupled to platform service releases) +- All existing CI tests continue to run + +## Feedback + +If you have concerns or questions about this migration, please comment on this issue. diff --git a/go.work b/go.work index 235792dad9..8c899125a5 100644 --- a/go.work +++ b/go.work @@ -8,6 +8,7 @@ use ( ./lib/flattening ./lib/identifier ./lib/ocrypto + ./otdfctl ./protocol/go ./sdk ./service diff --git a/otdfctl/CHANGELOG.md b/otdfctl/CHANGELOG.md new file mode 100644 index 0000000000..1e9221772f --- /dev/null +++ b/otdfctl/CHANGELOG.md @@ -0,0 +1,394 @@ +# Changelog + +## [0.30.0](https://github.com/opentdf/otdfctl/compare/v0.29.0...v0.30.0) (2026-03-31) + + +### Features + +* **core:** Add optional namespace flag for subject mappings and condtion sets ([#779](https://github.com/opentdf/otdfctl/issues/779)) ([9e849c4](https://github.com/opentdf/otdfctl/commit/9e849c4c80d8ba6f32a24a4be161dfe26d28cdde)) +* **core:** add scope support for client creds ([#752](https://github.com/opentdf/otdfctl/issues/752)) ([9ca9e43](https://github.com/opentdf/otdfctl/commit/9ca9e43394c67813a5fd1f506d174fbf33dcc492)) +* **core:** migrate registered resources ([#772](https://github.com/opentdf/otdfctl/issues/772)) ([2b49a7d](https://github.com/opentdf/otdfctl/commit/2b49a7deceaf9f227a6548e59c7c6e8cbf100b17)) +* **core:** optional namespace in actions commands and re-enable actions/RR tests ([#775](https://github.com/opentdf/otdfctl/issues/775)) ([29a2eb1](https://github.com/opentdf/otdfctl/commit/29a2eb13c8201f5ca059478169479dc5fea9de4e)) +* **core:** support namespaced registered resources ([#767](https://github.com/opentdf/otdfctl/issues/767)) ([4d786b5](https://github.com/opentdf/otdfctl/commit/4d786b5103580afae21ab6811e426f0b25eb6b3a)) + + +### Bug Fixes + +* **ci:** Temporarily skip namespaced-actions impacted BATS cases ([#773](https://github.com/opentdf/otdfctl/issues/773)) ([633728a](https://github.com/opentdf/otdfctl/commit/633728af0d7ce9cc6a1231a07315eafd23971d56)) +* **core:** bump toolchain to go 1.24.13 ([#747](https://github.com/opentdf/otdfctl/issues/747)) ([6804b93](https://github.com/opentdf/otdfctl/commit/6804b93c848bb56398bfdff09a78e1458493f5e7)) +* **core:** disable RR E2E tests ([#768](https://github.com/opentdf/otdfctl/issues/768)) ([0821b8c](https://github.com/opentdf/otdfctl/commit/0821b8c933b97a9550fdac47449adf8b533cb04a)) +* **core:** make namespacing registered resources optional ([#785](https://github.com/opentdf/otdfctl/issues/785)) ([8e6eb31](https://github.com/opentdf/otdfctl/commit/8e6eb3141feb7bc9c1a4e8cb7131cbf89f559eba)) +* **core:** refactor `ListAttributesValues` to use `Get` ([#769](https://github.com/opentdf/otdfctl/issues/769)) ([a82f7b7](https://github.com/opentdf/otdfctl/commit/a82f7b74a9fefd2dc59b019e9ad2877e25ac731a)) +* **core:** unsafe update result output values order ([#759](https://github.com/opentdf/otdfctl/issues/759)) ([baeba0f](https://github.com/opentdf/otdfctl/commit/baeba0f078fdad1b5fff018ff7b097eda794c703)) + +## [0.29.0](https://github.com/opentdf/otdfctl/compare/v0.28.0...v0.29.0) (2026-01-28) + + +### ⚠ BREAKING CHANGES + +* **core:** remove NanoTDF support ([#736](https://github.com/opentdf/otdfctl/issues/736)) + +### Features + +* **core:** Add allow_traversal to attribute defs. ([#739](https://github.com/opentdf/otdfctl/issues/739)) ([63d71b0](https://github.com/opentdf/otdfctl/commit/63d71b0c0dc93545f50f921f613880f99482c959)) + + +### Bug Fixes + +* **core:** obligations commands id and fqn flag exclusivity ([#731](https://github.com/opentdf/otdfctl/issues/731)) ([77ebbb4](https://github.com/opentdf/otdfctl/commit/77ebbb49d26b0b96abd254e9e177187504f7abb5)), closes [#728](https://github.com/opentdf/otdfctl/issues/728) +* **core:** remove NanoTDF support ([#736](https://github.com/opentdf/otdfctl/issues/736)) ([9528821](https://github.com/opentdf/otdfctl/commit/9528821ed1d2e728afa44af29e4d4ebf72030039)) + +## [0.28.0](https://github.com/opentdf/otdfctl/compare/v0.27.0...v0.28.0) (2025-12-16) + + +### ⚠ BREAKING CHANGES + +* **core:** Store output format to profile. ([#719](https://github.com/opentdf/otdfctl/issues/719)) + +### Features + +* **core:** Output to stdout, log to stderr. ([#716](https://github.com/opentdf/otdfctl/issues/716)) ([4f6e1e4](https://github.com/opentdf/otdfctl/commit/4f6e1e4883c2e1d5215835cd8893e51b01e6c358)) +* **core:** pass default slogger into SDK init ([#721](https://github.com/opentdf/otdfctl/issues/721)) ([c6bc084](https://github.com/opentdf/otdfctl/commit/c6bc084bf9856075a50f70168b88dd2c188488cc)) +* **core:** Store output format to profile. ([#719](https://github.com/opentdf/otdfctl/issues/719)) ([400ecec](https://github.com/opentdf/otdfctl/commit/400ecec5af8f9b96c716310b76bb493d3124748f)) + + +### Bug Fixes + +* **core:** Fix log-level flag ([#714](https://github.com/opentdf/otdfctl/issues/714)) ([84f191b](https://github.com/opentdf/otdfctl/commit/84f191b8c64ca06b692f855f0144ac9bcd2f56b9)) +* **core:** Print errors and messages with JSON if enabled in printer ([#724](https://github.com/opentdf/otdfctl/issues/724)) ([ce0256b](https://github.com/opentdf/otdfctl/commit/ce0256bf888745cb25e09e1a608b620824f73139)) + +## [0.27.0](https://github.com/opentdf/otdfctl/compare/v0.26.0...v0.27.0) (2025-12-03) + + +### ⚠ BREAKING CHANGES + +* **core:** Return pagination in responses. ([#684](https://github.com/opentdf/otdfctl/issues/684)) + +### Features + +* **core:** Filesystem as profile store ([#705](https://github.com/opentdf/otdfctl/issues/705)) ([47df5da](https://github.com/opentdf/otdfctl/commit/47df5dac8a0b3474f6ab145288886d0bd7031053)) + + +### Bug Fixes + +* **core:** Ensure IDs are displayed for keys. ([#681](https://github.com/opentdf/otdfctl/issues/681)) ([c5c9989](https://github.com/opentdf/otdfctl/commit/c5c9989f2a7ae9e8865e03ae761df830a4c54b15)) +* **core:** first set of manual lint fixes ([#700](https://github.com/opentdf/otdfctl/issues/700)) ([1f89120](https://github.com/opentdf/otdfctl/commit/1f89120a5590ac5b6aa5c64d51f3947cd3f29bb1)) +* **core:** Fix obligation smoke test ([#687](https://github.com/opentdf/otdfctl/issues/687)) ([c8e9b20](https://github.com/opentdf/otdfctl/commit/c8e9b2004f21ac87a0a0d4bce9c546796928ba2d)) +* **core:** improve TLS error handling UX when connecting to platform ([#708](https://github.com/opentdf/otdfctl/issues/708)) ([373df89](https://github.com/opentdf/otdfctl/commit/373df89875235f6f3acfc0bf9680ac7b057e04e0)) +* **core:** lint fixes that can be automatically resolved ([#699](https://github.com/opentdf/otdfctl/issues/699)) ([a2aedcb](https://github.com/opentdf/otdfctl/commit/a2aedcbc3fd3d1b31aed15de63bffb86e9b4c597)) +* **core:** many manually resolved lint fixes ([#701](https://github.com/opentdf/otdfctl/issues/701)) ([bb998cf](https://github.com/opentdf/otdfctl/commit/bb998cfd20cf1123c66daf6a237a648f4bf144e8)) +* **core:** restructure cmd package to resolve remaining lint issues ([#702](https://github.com/opentdf/otdfctl/issues/702)) ([5d677e1](https://github.com/opentdf/otdfctl/commit/5d677e1bda9abdf1c88b21c19a1b7b423368fb32)) +* **core:** Return pagination in responses. ([#684](https://github.com/opentdf/otdfctl/issues/684)) ([666ac2f](https://github.com/opentdf/otdfctl/commit/666ac2f57b532000cc715d04f1312800dd560049)) +* **main:** Add SCS creation in setup_file ([#693](https://github.com/opentdf/otdfctl/issues/693)) ([33ae971](https://github.com/opentdf/otdfctl/commit/33ae9712944e5175a047c8e1eba2cbe08a021955)) +* **main:** Subject mapping tests ([#691](https://github.com/opentdf/otdfctl/issues/691)) ([6c137c0](https://github.com/opentdf/otdfctl/commit/6c137c04b8c407cd9117c0828657eb9e5c3c2819)) +* **main:** Update flaky subject mapping tests ([#694](https://github.com/opentdf/otdfctl/issues/694)) ([b3cd4df](https://github.com/opentdf/otdfctl/commit/b3cd4df40b5e7e315ff4109f34b8d5d8ffd36c0f)) +* **main:** Use assertion helpers ([#692](https://github.com/opentdf/otdfctl/issues/692)) ([2956244](https://github.com/opentdf/otdfctl/commit/2956244d6d9947e7d09b20070f31dd0cff7f1d5b)) + +## [0.26.0](https://github.com/opentdf/otdfctl/compare/v0.25.0...v0.26.0) (2025-10-22) + + +### Features + +* **core:** Add list obligation triggers. ([#677](https://github.com/opentdf/otdfctl/issues/677)) ([ac3bd5e](https://github.com/opentdf/otdfctl/commit/ac3bd5e55d0101d005b4bc8c6e24b6595d4ff859)) +* **core:** Append required obligations to error output ([#673](https://github.com/opentdf/otdfctl/issues/673)) ([7eae582](https://github.com/opentdf/otdfctl/commit/7eae58246047176d68a27022ecb862822a573794)) + + +### Bug Fixes + +* **core:** Provider config manager table field empty ([#668](https://github.com/opentdf/otdfctl/issues/668)) ([89871f6](https://github.com/opentdf/otdfctl/commit/89871f6fde4d5d1b6cfca375723424bedcefc1f2)) +* **core:** Use fqn populated on obligation value. ([#679](https://github.com/opentdf/otdfctl/issues/679)) ([7dd626e](https://github.com/opentdf/otdfctl/commit/7dd626ecb380725ef4c16fc98b44d3c861cd8244)) +* validate --public-key-pem value on key creation ([#678](https://github.com/opentdf/otdfctl/issues/678)) ([b1e69ef](https://github.com/opentdf/otdfctl/commit/b1e69efd5b8a22499ac95a1b9bc08f08b415e0a3)) + +## [0.25.0](https://github.com/opentdf/otdfctl/compare/v0.24.0...v0.25.0) (2025-10-06) + + +### Features + +* add support for provider manager column ([#660](https://github.com/opentdf/otdfctl/issues/660)) ([fe4e50b](https://github.com/opentdf/otdfctl/commit/fe4e50ba9c1773f0b12622a924a4317ccdbe2ed6)) +* **core:** Add legacy flag to import and list. ([#641](https://github.com/opentdf/otdfctl/issues/641)) ([ffd0dc0](https://github.com/opentdf/otdfctl/commit/ffd0dc0fc84ef0cee3b896fc939d1c244da5728d)) +* **core:** Add obligation triggers ([#656](https://github.com/opentdf/otdfctl/issues/656)) ([8f6087f](https://github.com/opentdf/otdfctl/commit/8f6087fd2531628dda64eb8b0133830c6a21f9f6)) +* **core:** Adds policy-mode encrypt param ([#633](https://github.com/opentdf/otdfctl/issues/633)) ([9e83016](https://github.com/opentdf/otdfctl/commit/9e830168a38c0803396fd5c4c188fa62c0ccf5a0)) +* **core:** Create/Update triggers via obligation values. ([#658](https://github.com/opentdf/otdfctl/issues/658)) ([2a2f0c6](https://github.com/opentdf/otdfctl/commit/2a2f0c6c87ff9e7a543e4c4634d0be96dfe9e8e3)) +* **core:** obligations defs + vals CRUD ([#639](https://github.com/opentdf/otdfctl/issues/639)) ([3a3df0d](https://github.com/opentdf/otdfctl/commit/3a3df0d7b862fc56635b18d73af784ecd3066ae2)) + + +### Bug Fixes + +* **core:** add missing port flag ([#638](https://github.com/opentdf/otdfctl/issues/638)) ([c9bb4e5](https://github.com/opentdf/otdfctl/commit/c9bb4e50d0690cecda5ded7a41f572a10d18f6a6)) +* **core:** Clarifies not_found in attrs ([#649](https://github.com/opentdf/otdfctl/issues/649)) ([d46bd0f](https://github.com/opentdf/otdfctl/commit/d46bd0f3c60bc8b7f47789d93f842271791cf824)) +* **core:** render kas-registry key list-mappings table rows ([#663](https://github.com/opentdf/otdfctl/issues/663)) ([fb39718](https://github.com/opentdf/otdfctl/commit/fb39718aa23a626186dcb54d9115b837b66a9b79)) + +## [0.24.0](https://github.com/opentdf/otdfctl/compare/v0.23.0...v0.24.0) (2025-07-29) + + +### Features + +* **core:** Delete kas keys ([#627](https://github.com/opentdf/otdfctl/issues/627)) ([e2acb67](https://github.com/opentdf/otdfctl/commit/e2acb670b66ffb8d8c889a240f784c1a02ec42b5)) +* **core:** expose registered resources commands ([#631](https://github.com/opentdf/otdfctl/issues/631)) ([18530b8](https://github.com/opentdf/otdfctl/commit/18530b8c623c67afcd7515ccd2bacb9d2de14fef)) +* **core:** Key mappings command ([#623](https://github.com/opentdf/otdfctl/issues/623)) ([28403c6](https://github.com/opentdf/otdfctl/commit/28403c600e0fff9404d6be79207330046237b5d4)) +* **core:** Registered Resources - action attribute values update confirmation ([#620](https://github.com/opentdf/otdfctl/issues/620)) ([2ad0b9e](https://github.com/opentdf/otdfctl/commit/2ad0b9e9260785ac5bd7603b0d7f95b8957cba11)) + +## [0.23.0](https://github.com/opentdf/otdfctl/compare/v0.22.0...v0.23.0) (2025-07-01) + + +### Features + +* **core:** Import keys. ([#617](https://github.com/opentdf/otdfctl/issues/617)) ([4dc69e6](https://github.com/opentdf/otdfctl/commit/4dc69e6eaf2cdb23116b97ca2448bbbd57346f49)) + +## [0.22.0](https://github.com/opentdf/otdfctl/compare/v0.21.0...v0.22.0) (2025-06-24) + + +### ⚠ BREAKING CHANGES + +* remove the ability to assign grants ([#604](https://github.com/opentdf/otdfctl/issues/604)) + +### Features + +* **core:** dynamic port allocation ([#606](https://github.com/opentdf/otdfctl/issues/606)) ([75552e1](https://github.com/opentdf/otdfctl/commit/75552e187eef204b03b1d13d55920fa43ec3cf30)) +* **core:** Uncomment code and pull in new protos. ([#594](https://github.com/opentdf/otdfctl/issues/594)) ([2883e50](https://github.com/opentdf/otdfctl/commit/2883e5060ca1f9d22f9a9500293fc407e7f4bcfd)) +* **core:** Unhide key commands. ([#607](https://github.com/opentdf/otdfctl/issues/607)) ([a3660d9](https://github.com/opentdf/otdfctl/commit/a3660d9e8271e3fd179e6521eab02a2b096a01db)) +* remove the ability to assign grants ([#604](https://github.com/opentdf/otdfctl/issues/604)) ([c9f0d82](https://github.com/opentdf/otdfctl/commit/c9f0d822747a62a6253c441ede144238715da50b)) + + +### Bug Fixes + +* add more Deprecated text to kas-grants ([#605](https://github.com/opentdf/otdfctl/issues/605)) ([2106d2f](https://github.com/opentdf/otdfctl/commit/2106d2f5189de49fe05b94025228474ffdb026ae)) +* **ci:** Trigger for release-please (testing) ([#580](https://github.com/opentdf/otdfctl/issues/580)) ([5cd33f9](https://github.com/opentdf/otdfctl/commit/5cd33f9f9b5fb66b2cc9c0c795bd84cf10630298)) +* **core:** Change base key name so tests run last. ([#611](https://github.com/opentdf/otdfctl/issues/611)) ([464b179](https://github.com/opentdf/otdfctl/commit/464b179a3134890943d8319bdee41cbad9078d64)) +* **core:** Move key management under policy. ([#597](https://github.com/opentdf/otdfctl/issues/597)) ([d657e96](https://github.com/opentdf/otdfctl/commit/d657e96cab3afc516437ae08321ab45aff376460)) +* disable kas-registry --public-keys and --publickey-remote flags ([#603](https://github.com/opentdf/otdfctl/issues/603)) ([279bbbd](https://github.com/opentdf/otdfctl/commit/279bbbd8ced14765c97ae3928421d38737ac0a8d)) +* enforce hex encoded wrapping-key ([#581](https://github.com/opentdf/otdfctl/issues/581)) ([416e215](https://github.com/opentdf/otdfctl/commit/416e215abf0c910aa4d18dc84729f89ea578fd4d)) +* **main:** Use cmd.Context for resource mapping group commands ([#592](https://github.com/opentdf/otdfctl/issues/592)) ([b5d8b6f](https://github.com/opentdf/otdfctl/commit/b5d8b6f6c335483873cec90363d94e0196d18b14)) + +## [0.21.0](https://github.com/opentdf/otdfctl/compare/v0.20.0...v0.21.0) (2025-05-29) + + +### Features + +* Add initial Dependency Review configuration ([#551](https://github.com/opentdf/otdfctl/issues/551)) ([b622666](https://github.com/opentdf/otdfctl/commit/b6226660c1d75e133a8ead456efcab74de4b4fc0)) +* **core:** Add base key cmds ([#563](https://github.com/opentdf/otdfctl/issues/563)) ([edfd6c0](https://github.com/opentdf/otdfctl/commit/edfd6c08dc9b84f2cbfc79643ccc266a45ce58fd)) +* **core:** DSPX-18 clean up Go context usage to follow best practices ([#558](https://github.com/opentdf/otdfctl/issues/558)) ([a2c9f8b](https://github.com/opentdf/otdfctl/commit/a2c9f8b13cbab740b46262f70aecc82a94f3d788)) +* **core:** DSPX-608 - Deprecate public_client_id ([#555](https://github.com/opentdf/otdfctl/issues/555)) ([8d396bd](https://github.com/opentdf/otdfctl/commit/8d396bd022126524d9d20daa03ec6ca262cf4406)) +* **core:** DSPX-608 - require clientID for login ([#553](https://github.com/opentdf/otdfctl/issues/553)) ([580172e](https://github.com/opentdf/otdfctl/commit/580172e1861b54366f4914a141e459fe3221a16d)) +* **core:** DSPX-896 add registered resources CRUD ([#559](https://github.com/opentdf/otdfctl/issues/559)) ([8e7475e](https://github.com/opentdf/otdfctl/commit/8e7475ef8aab91d28ab7efd320af13dc5ab53d3b)) +* **core:** KAS allowlist options ([#539](https://github.com/opentdf/otdfctl/issues/539)) ([af7978f](https://github.com/opentdf/otdfctl/commit/af7978f86ced38543b31b792e008654071333789)) +* **core:** key management operations ([#533](https://github.com/opentdf/otdfctl/issues/533)) ([d4f6aaa](https://github.com/opentdf/otdfctl/commit/d4f6aaac3f6fc1b50fbc988e5d34a32de0ed9f64)) +* **main:** add actions CRUD and e2e tests ([#523](https://github.com/opentdf/otdfctl/issues/523)) ([2fb9ec7](https://github.com/opentdf/otdfctl/commit/2fb9ec7336da5731b868da94f0bbd5b2f226ede1)) +* **main:** refactor actions within existing CLI policy object CRUD ([#543](https://github.com/opentdf/otdfctl/issues/543)) ([9ab1a58](https://github.com/opentdf/otdfctl/commit/9ab1a58418643ea709aefb08e3f5ca8bd06235f4)) +* **core:** Resource mapping groups ([#567](https://github.com/opentdf/otdfctl/issues/567)) ([03fa307](https://github.com/opentdf/otdfctl/commit/03fa307b3ab91f25baeb74e30fde6eeec6d479a1)) +* **core:** Update key mgmt flags to consistent format ([#570](https://github.com/opentdf/otdfctl/issues/570)) ([#846f96c](https://github.com/opentdf/otdfctl/commit/846f96cb9adfe03e355c9e64b559f1c11d84a86f)) +* **core:** Rotate Key ([#572](https://github.com/opentdf/otdfctl/issues/572)) ([afd0043](https://github.com/opentdf/otdfctl/commit/afd0043f1ea66f0b371a95b556320551f73749bb)) + + +### Bug Fixes + +* **ci:** ci job should run on changes to GHA ([#530](https://github.com/opentdf/otdfctl/issues/530)) ([1d296ca](https://github.com/opentdf/otdfctl/commit/1d296ca8fac889a6e776ad381df999a2fcf9d6ce)) +* **main:** Pass the full url when building the sdk object ([#544](https://github.com/opentdf/otdfctl/issues/544)) ([8b836f0](https://github.com/opentdf/otdfctl/commit/8b836f0fa3aa414c3ab19d830f4d1f833d3ae61d)) + +## [0.20.0](https://github.com/opentdf/otdfctl/compare/v0.19.0...v0.20.0) (2025-04-08) + + +### Features + +* **core:** add aliases for profile command ([#510](https://github.com/opentdf/otdfctl/issues/510)) ([45c633d](https://github.com/opentdf/otdfctl/commit/45c633da6b00b04a8c92686521d25144048ac62c)) +* **core:** Add support for WithTargetMode encrypt option ([#519](https://github.com/opentdf/otdfctl/issues/519)) ([a0ab213](https://github.com/opentdf/otdfctl/commit/a0ab2136be0b1d39e16a7522210f493fd797089d)) + + +### Bug Fixes + +* **core:** bump jwt dep and remove outdated version ([#520](https://github.com/opentdf/otdfctl/issues/520)) ([77bb9ca](https://github.com/opentdf/otdfctl/commit/77bb9ca9a0741ab7b920cc00f264a021064b117c)) + +## [0.19.0](https://github.com/opentdf/otdfctl/compare/v0.18.0...v0.19.0) (2025-03-05) + + +### Features + +* **core:** support for ec-wrapping ([#499](https://github.com/opentdf/otdfctl/issues/499)) ([e839445](https://github.com/opentdf/otdfctl/commit/e839445181c89447d9a2374d54ce5ea4c3f46320)) + + +### Bug Fixes + +* **core:** mark new algorithm flags experimental ([#501](https://github.com/opentdf/otdfctl/issues/501)) ([95e00bf](https://github.com/opentdf/otdfctl/commit/95e00bf3daa8eb05196a5839488a4718c2230210)) + +## [0.18.0](https://github.com/opentdf/otdfctl/compare/v0.17.1...v0.18.0) (2025-02-25) + + +### Features + +* Assertion verification ([#452](https://github.com/opentdf/otdfctl/issues/452)) ([5a8fe0d](https://github.com/opentdf/otdfctl/commit/5a8fe0d64088b74c95d3376e4a2a5a47d680d9c0)) +* **core:** Adding examples docs, mainly policy commands ([#461](https://github.com/opentdf/otdfctl/issues/461)) ([04c1743](https://github.com/opentdf/otdfctl/commit/04c17439bb5f68fb5d44ba96cb457ce9ca072250)) +* **core:** bump SDK and consume new platform connection validation ([#493](https://github.com/opentdf/otdfctl/issues/493)) ([1106b54](https://github.com/opentdf/otdfctl/commit/1106b54e73f9ceb711ff19d15cd08bf1cebbb29f)) +* **core:** Shows SDK version and spec info ([#474](https://github.com/opentdf/otdfctl/issues/474)) ([5a685c4](https://github.com/opentdf/otdfctl/commit/5a685c4e36cf524c4f594fac42cfec30f62a6e83)) + +## [0.17.1](https://github.com/opentdf/otdfctl/compare/v0.17.0...v0.17.1) (2024-12-09) + + +### Bug Fixes + +* **core:** kasr creation JSON example ([#453](https://github.com/opentdf/otdfctl/issues/453)) ([192c7b2](https://github.com/opentdf/otdfctl/commit/192c7b2975a4ab6f648ab7924e20e70535ce04b2)) + +## [0.17.0](https://github.com/opentdf/otdfctl/compare/v0.16.0...v0.17.0) (2024-12-05) + + +### Features + +* **core:** pagination of LIST commands ([#447](https://github.com/opentdf/otdfctl/issues/447)) ([673a064](https://github.com/opentdf/otdfctl/commit/673a06424d30e706798b9a1fa1bbfd9b4601e765)) +* **core:** subject condition set prune ([#439](https://github.com/opentdf/otdfctl/issues/439)) ([c4c8b8b](https://github.com/opentdf/otdfctl/commit/c4c8b8b276b2189df74e6cf30e14abac9369d97e)) + + +### Bug Fixes + +* **core:** kas registry get should allow -i 'id' flag shorthand ([#434](https://github.com/opentdf/otdfctl/issues/434)) ([bed3701](https://github.com/opentdf/otdfctl/commit/bed3701d89510ee78c3aed43b1a072e41ee3873f)) +* **core:** sm list should provide value fqn instead of just value string ([#438](https://github.com/opentdf/otdfctl/issues/438)) ([9a7cb72](https://github.com/opentdf/otdfctl/commit/9a7cb7242e0e39ccc2b54425028638fa0c5e3f9f)) + +## [0.16.0](https://github.com/opentdf/otdfctl/compare/v0.15.0...v0.16.0) (2024-11-20) + + +### Features + +* assertion verification disable ([#419](https://github.com/opentdf/otdfctl/issues/419)) ([acf5702](https://github.com/opentdf/otdfctl/commit/acf57028f1481f432b6b0c3c7a3e2c2261ac739f)) +* **core:** add `subject-mappings match` to CLI ([#413](https://github.com/opentdf/otdfctl/issues/413)) ([bc56c19](https://github.com/opentdf/otdfctl/commit/bc56c199a73b12b8c90045d1b6f9cc6fdec16c54)) +* **core:** add optional name to kas registry CRUD commands ([#429](https://github.com/opentdf/otdfctl/issues/429)) ([f675d86](https://github.com/opentdf/otdfctl/commit/f675d86c83205232db407d6609e80fa865a3998e)) +* **core:** adds assertions to encrypt subcommand ([#408](https://github.com/opentdf/otdfctl/issues/408)) ([8f0e906](https://github.com/opentdf/otdfctl/commit/8f0e906c1dfe99fe6aa5f2ff43d02f0da90474cf)) +* **core:** adds storeFile to save encrypted profiles to disk and updates auth to propagate tlsNoVerify ([#420](https://github.com/opentdf/otdfctl/issues/420)) ([f709e01](https://github.com/opentdf/otdfctl/commit/f709e014bf3f82a2808eae5df76b3667730c36ef)) +* refactor encrypt and decrypt + CLI examples ([#418](https://github.com/opentdf/otdfctl/issues/418)) ([e681823](https://github.com/opentdf/otdfctl/commit/e681823ad54ddf70f4aa2215438d69a3d02cf6eb)) +* support --with-access-token for auth ([#409](https://github.com/opentdf/otdfctl/issues/409)) ([856efa4](https://github.com/opentdf/otdfctl/commit/856efa4d61bb24b05f3a98943b94600ff77536fa)) + + +### Bug Fixes + +* **core:** dev selectors employ flattening from platform instead of jq ([#411](https://github.com/opentdf/otdfctl/issues/411)) ([57966ff](https://github.com/opentdf/otdfctl/commit/57966ffadcc61e1611869171bd3fc85723492fb7)) +* **core:** improve readability of TDF methods ([#424](https://github.com/opentdf/otdfctl/issues/424)) ([a88d386](https://github.com/opentdf/otdfctl/commit/a88d386b3dfe6e7bf210c632c92eb54069c1c5b8)) +* **core:** remove trailing slashes on host/platformEndpoint ([#415](https://github.com/opentdf/otdfctl/issues/415)) ([2ffd3c7](https://github.com/opentdf/otdfctl/commit/2ffd3c7707aa5c610f952d3499a7bfc76e8feca8)), closes [#414](https://github.com/opentdf/otdfctl/issues/414) +* **core:** revert profiles file system storage last commit ([#427](https://github.com/opentdf/otdfctl/issues/427)) ([79f2079](https://github.com/opentdf/otdfctl/commit/79f2079342bfbf210e07ce7cc6714deafea12b29)) +* updates sdk to 0.3.19 with GetTdfType fixes ([#425](https://github.com/opentdf/otdfctl/issues/425)) ([0a9adfe](https://github.com/opentdf/otdfctl/commit/0a9adfe416b966b09db4b9ee60fa379db93ede76)) + +## [0.15.0](https://github.com/opentdf/otdfctl/compare/v0.14.0...v0.15.0) (2024-10-15) + + +### Features + +* **core:** DSP-51 - deprecate PublicKey local field ([#400](https://github.com/opentdf/otdfctl/issues/400)) ([1955800](https://github.com/opentdf/otdfctl/commit/1955800fcd63c4d5044517ec0355a82c0e687f1b)) +* **core:** Update Resource Mapping delete to use get before delete for cli output ([#398](https://github.com/opentdf/otdfctl/issues/398)) ([79f2a42](https://github.com/opentdf/otdfctl/commit/79f2a423380cbd3f4a7805c4ec35d4657a9c0d5c)) + + +### Bug Fixes + +* **core:** build with latest opentdf releases ([#404](https://github.com/opentdf/otdfctl/issues/404)) ([969b82b](https://github.com/opentdf/otdfctl/commit/969b82b5cf90405002ac2da4a31b022dca9dfa37)) + +## [0.14.0](https://github.com/opentdf/otdfctl/compare/v0.13.0...v0.14.0) (2024-10-01) + + +### Features + +* **ci:** add e2e tests for subject mappings, support for --force delete ([#388](https://github.com/opentdf/otdfctl/issues/388)) ([c1f544b](https://github.com/opentdf/otdfctl/commit/c1f544b1079f52bfccb96c4c9e0b579a6854ad58)) +* **ci:** add tests for subject condition sets, and --force delete flag ([#389](https://github.com/opentdf/otdfctl/issues/389)) ([c6d2abc](https://github.com/opentdf/otdfctl/commit/c6d2abcd4afe78d92fd285e5c77fecdfe806ed5d)), closes [#331](https://github.com/opentdf/otdfctl/issues/331) +* **ci:** e2e attribute definitions tests ([#384](https://github.com/opentdf/otdfctl/issues/384)) ([2894391](https://github.com/opentdf/otdfctl/commit/28943915f19e0fb565cfb38cfebdd6fde21c019a)), closes [#327](https://github.com/opentdf/otdfctl/issues/327) +* **core:** export manual functions for CLI wrappers to consume ([#397](https://github.com/opentdf/otdfctl/issues/397)) ([aa0bf95](https://github.com/opentdf/otdfctl/commit/aa0bf95a39dfc0aec4155e498a2096cbd158efdd)) +* **core:** resource mappings LIST fix, delete --force support, and e2e tests ([#387](https://github.com/opentdf/otdfctl/issues/387)) ([326e74b](https://github.com/opentdf/otdfctl/commit/326e74b37d0abfb4ad50deadaa1ed46ecf9f8a5d)), closes [#386](https://github.com/opentdf/otdfctl/issues/386) + + +### Bug Fixes + +* **core:** remove duplicate titling of help manual ([#391](https://github.com/opentdf/otdfctl/issues/391)) ([cb8db69](https://github.com/opentdf/otdfctl/commit/cb8db69ec4df42c7f230fbd87142bfbcd2d3940f)) + +## [0.13.0](https://github.com/opentdf/otdfctl/compare/v0.12.2...v0.13.0) (2024-09-12) + + +### Features + +* add cli test mode and profile tests ([#313](https://github.com/opentdf/otdfctl/issues/313)) ([e0bc183](https://github.com/opentdf/otdfctl/commit/e0bc1836e8b5f14c87b5d572ad7937924c76d860)) +* **ci:** make e2e test workflow reusable ([#365](https://github.com/opentdf/otdfctl/issues/365)) ([d94408c](https://github.com/opentdf/otdfctl/commit/d94408cc2898d46b3444e874c035ff2bffe451f4)) +* **ci:** namespaces e2e tests and test suite improvements ([#351](https://github.com/opentdf/otdfctl/issues/351)) ([ce28555](https://github.com/opentdf/otdfctl/commit/ce285554866bf89ee8aa2df4a4b426548a58b59a)) +* **ci:** reusable platform composite action in e2e tests ([#369](https://github.com/opentdf/otdfctl/issues/369)) ([f7d5a1c](https://github.com/opentdf/otdfctl/commit/f7d5a1c07304bee14dfc92fa81bd65389e76d9f6)) +* **core:** add ecdsa-binding encrypt flag ([#360](https://github.com/opentdf/otdfctl/issues/360)) ([8702ec0](https://github.com/opentdf/otdfctl/commit/8702ec007b6d1354b6c0366e6b375f26216dfde1)) +* **core:** adds missing long manual output docs ([#362](https://github.com/opentdf/otdfctl/issues/362)) ([8e1390f](https://github.com/opentdf/otdfctl/commit/8e1390f20c17a5900c586f94384af76ffd9a2844)), closes [#359](https://github.com/opentdf/otdfctl/issues/359) +* **core:** kas-grants list ([#346](https://github.com/opentdf/otdfctl/issues/346)) ([7f51282](https://github.com/opentdf/otdfctl/commit/7f512825eab814e3c130e3fe4e8ed85ecbe2d146)), closes [#253](https://github.com/opentdf/otdfctl/issues/253) + + +### Bug Fixes + +* **ci:** e2e workflow should be fully reusable ([#368](https://github.com/opentdf/otdfctl/issues/368)) ([cc1e2b9](https://github.com/opentdf/otdfctl/commit/cc1e2b938fb0c8c4cf64d735f2961f7c9cae79fa)) +* **ci:** enhance lint config and resolve all lint issues ([#363](https://github.com/opentdf/otdfctl/issues/363)) ([5c1dbf1](https://github.com/opentdf/otdfctl/commit/5c1dbf1f5e441ca0ebd8cfcca145a77b623f3638)) +* **core:** GOOS, error message fixes ([#378](https://github.com/opentdf/otdfctl/issues/378)) ([623a82a](https://github.com/opentdf/otdfctl/commit/623a82ad3c1ed698a83eed54cf15a4f552096728)), closes [#380](https://github.com/opentdf/otdfctl/issues/380) +* **core:** metadata rendering cleanup ([#293](https://github.com/opentdf/otdfctl/issues/293)) ([ed21f81](https://github.com/opentdf/otdfctl/commit/ed21f81863450fd6167106711392e713a43c55be)) +* **core:** wire attribute value FQNs to encrypt ([#370](https://github.com/opentdf/otdfctl/issues/370)) ([21f9b80](https://github.com/opentdf/otdfctl/commit/21f9b80cdee7d695a308937b08dbc768d11fbbd5)) +* refactor to support varying print output ([#350](https://github.com/opentdf/otdfctl/issues/350)) ([d6932f3](https://github.com/opentdf/otdfctl/commit/d6932f30d9f653e46b32761a3257f3555ef0a6eb)) + +## [0.12.2](https://github.com/opentdf/otdfctl/compare/v0.12.1...v0.12.2) (2024-08-27) + + +### Bug Fixes + +* **core:** improve KASR docs and add spellcheck GHA to pipeline ([#323](https://github.com/opentdf/otdfctl/issues/323)) ([a77cf30](https://github.com/opentdf/otdfctl/commit/a77cf30dc8077d034cb4c9df8cc94712b1a17dff)), closes [#335](https://github.com/opentdf/otdfctl/issues/335) [#337](https://github.com/opentdf/otdfctl/issues/337) +* create new http client to ignore tls verification ([#324](https://github.com/opentdf/otdfctl/issues/324)) ([4d4afb7](https://github.com/opentdf/otdfctl/commit/4d4afb7e5b6411bb08a92bc53181ac5730ca1992)) + +## [0.12.1](https://github.com/opentdf/otdfctl/compare/v0.12.0...v0.12.1) (2024-08-26) + + +### Bug Fixes + +* **core:** remove documentation that cached kas pubkey is base64 ([#320](https://github.com/opentdf/otdfctl/issues/320)) ([fce8f44](https://github.com/opentdf/otdfctl/commit/fce8f44f767f35ccc4863f88d46e7ffcbd80f37a)), closes [#321](https://github.com/opentdf/otdfctl/issues/321) + +## [0.12.0](https://github.com/opentdf/otdfctl/compare/v0.11.4...v0.12.0) (2024-08-23) + + +### Features + +* **ci:** attr e2e tests with mixed casing ([#315](https://github.com/opentdf/otdfctl/issues/315)) ([50ce712](https://github.com/opentdf/otdfctl/commit/50ce712eab38f6686611e2b306bda5cacd55c28e)) +* **core:** kasr cached keys to deprecate local ([#318](https://github.com/opentdf/otdfctl/issues/318)) ([5419cc3](https://github.com/opentdf/otdfctl/commit/5419cc39e143eb484f836ca1ee671d626d5e2c60)), closes [#317](https://github.com/opentdf/otdfctl/issues/317) + +## [0.11.4](https://github.com/opentdf/otdfctl/compare/v0.11.3...v0.11.4) (2024-08-22) + + +### Bug Fixes + +* update workflow permissions ([#310](https://github.com/opentdf/otdfctl/issues/310)) ([3979fe8](https://github.com/opentdf/otdfctl/commit/3979fe85c9ab6511376d98b672cbfebddbf9bb84)) + +## [0.11.3](https://github.com/opentdf/otdfctl/compare/v0.11.2...v0.11.3) (2024-08-22) + + +### Bug Fixes + +* **core:** do not import unused fmt ([#306](https://github.com/opentdf/otdfctl/issues/306)) ([0dc552d](https://github.com/opentdf/otdfctl/commit/0dc552d3d6814f910c04d5f8cefa35404b4945f5)) +* **core:** nil panic on set-default ([#304](https://github.com/opentdf/otdfctl/issues/304)) ([92bbfa3](https://github.com/opentdf/otdfctl/commit/92bbfa32ae42b73b68551c2f9d3551d357bc5922)) +* **core:** warn and do now allow deletion of default profile ([#308](https://github.com/opentdf/otdfctl/issues/308)) ([fdd8167](https://github.com/opentdf/otdfctl/commit/fdd8167e8e2b22d652b48d796a756f86398bfd3c)) +* make file not building correctly ([#307](https://github.com/opentdf/otdfctl/issues/307)) ([64eb821](https://github.com/opentdf/otdfctl/commit/64eb82170fdcc50396194271be358bf9c9d43049)) + +## [0.11.2](https://github.com/opentdf/otdfctl/compare/v0.11.1...v0.11.2) (2024-08-22) + + +### Bug Fixes + +* disable tagging ([#302](https://github.com/opentdf/otdfctl/issues/302)) ([2b5db85](https://github.com/opentdf/otdfctl/commit/2b5db852ed0088e61f1180500135cd1865f9798b)) + +## [0.11.1](https://github.com/opentdf/otdfctl/compare/v0.11.0...v0.11.1) (2024-08-22) + + +### Bug Fixes + +* release-please tweak ([#300](https://github.com/opentdf/otdfctl/issues/300)) ([29fc836](https://github.com/opentdf/otdfctl/commit/29fc8360ae0b701aefe70b25d1838f442fd7eb8d)) + +## [0.11.0](https://github.com/opentdf/otdfctl/compare/v0.10.0...v0.11.0) (2024-08-22) + + +### Features + +* move git checkout before tagging ([#298](https://github.com/opentdf/otdfctl/issues/298)) ([1114e25](https://github.com/opentdf/otdfctl/commit/1114e25a90946e85622c8ff7a7befbf18beb4ba1)) + +## [0.10.0](https://github.com/opentdf/otdfctl/compare/v0.9.4...v0.10.0) (2024-08-22) + + +### Features + +* add profile support for cli ([#289](https://github.com/opentdf/otdfctl/issues/289)) ([15700f3](https://github.com/opentdf/otdfctl/commit/15700f3375196595e4a0ea3a7a6dea4da06d8612)) +* **core:** add scaffolding and POC for auth code flow ([#144](https://github.com/opentdf/otdfctl/issues/144)) ([03ecbfb](https://github.com/opentdf/otdfctl/commit/03ecbfb4f689f4a9f161a5a03d80efd50f728780)) +* **core:** support kas grants to namespaces ([#292](https://github.com/opentdf/otdfctl/issues/292)) ([f2c6689](https://github.com/opentdf/otdfctl/commit/f2c6689d2f775b1aed907d553c42d87c8464e6c7)), closes [#269](https://github.com/opentdf/otdfctl/issues/269) +* improve auth with client credentials ([#286](https://github.com/opentdf/otdfctl/issues/286)) ([9c4968f](https://github.com/opentdf/otdfctl/commit/9c4968f48d1ba23a61ed5c8ad23a109bf141ba56)) +* improve auth with client credentials ([#296](https://github.com/opentdf/otdfctl/issues/296)) ([0f533c7](https://github.com/opentdf/otdfctl/commit/0f533c7278a53ddd90656b3c7efcaee1c5bfd957)) + + +### Bug Fixes + +* **core:** bump platform deps ([#276](https://github.com/opentdf/otdfctl/issues/276)) ([e4ced99](https://github.com/opentdf/otdfctl/commit/e4ced996ae336b9db6db88906683f6600a2e5bf4)) +* reduce prints ([#277](https://github.com/opentdf/otdfctl/issues/277)) ([8b5734a](https://github.com/opentdf/otdfctl/commit/8b5734a18636071566fd8c4cfc808f3f240a02a5)) diff --git a/otdfctl/Makefile b/otdfctl/Makefile new file mode 100644 index 0000000000..c6fc513e4c --- /dev/null +++ b/otdfctl/Makefile @@ -0,0 +1,95 @@ +# We're going to be using this Makefile as a sort of task runner, for all sorts of operations in this project + +# first we'll grab the current version from our ENV VAR (added by our CI) - see here: https://github.com/marketplace/actions/version-increment +BINARY_NAME := otdfctl +CURR_VERSION := ${SEM_VER} +COMMIT_SHA := ${COMMIT_SHA} +BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') + +GO_MOD_LINE = $(shell head -n 1 go.mod | cut -c 8-) +GO_MOD_NAME = $(word 1,$(subst /, ,$(GO_MOD_LINE))) +APP_CFG = $(GO_MOD_LINE)/pkg/config + +GO_BUILD_FLAGS=-ldflags " \ + -X $(APP_CFG).Version=${CURR_VERSION} \ + -X $(APP_CFG).CommitSha=${COMMIT_SHA} \ + -X $(APP_CFG).BuildTime=${BUILD_TIME} \ +" +GO_BUILD_PREFIX=$(TARGET_DIR)/$(BINARY_NAME)-${CURR_VERSION} + +# If commit sha is not available try git +ifndef COMMIT_SHA + COMMIT_SHA := $(shell git rev-parse HEAD) +endif + +# If current version is not available try git +ifndef CURR_VERSION + CURR_VERSION := $(shell git describe --tags --always) +endif + +# Default target executed when no arguments are given to make. +# NOTE: .PHONY is used to indicate that the target is not a file (e.g. there is no file called 'build-darwin-amd64', instead the .PHONY directive tells make that the proceeding target is a command to be executed, not a file to be generated) +.PHONY: all +all: run +.DEFAULT_GOAL := run + +# Target directory for compiled binaries +TARGET_DIR=target + +# Output directory for the zipped artifacts +OUTPUT_DIR=output + +# Build commands for each platform (extra hyphen used in windows to avoid issues with the .exe extension) +PLATFORMS := \ + darwin-amd64 \ + darwin-arm64 \ + linux-amd64 \ + linux-arm \ + linux-arm64 \ + windows-amd64-.exe \ + windows-arm-.exe \ + windows-arm64-.exe + +build: test clean $(addprefix build-,$(PLATFORMS)) zip-builds verify-checksums + +build-%: + GOOS=$(word 1,$(subst -, ,$*)) \ + GOARCH=$(word 2,$(subst -, ,$*)) \ + go build $(GO_BUILD_FLAGS) \ + -o $(GO_BUILD_PREFIX)-$(word 1,$(subst -, ,$*))-$(word 2,$(subst -, ,$*))$(word 3,$(subst -, ,$*)) + +zip-builds: + ./scripts/zip-builds.sh $(BINARY_NAME)-$(CURR_VERSION) $(TARGET_DIR) $(OUTPUT_DIR) + +verify-checksums: + ./scripts/verify-checksums.sh $(OUTPUT_DIR) $(BINARY_NAME)-$(CURR_VERSION)_checksums.txt + +# Target for running the project (adjust as necessary for your project) +.PHONY: run +run: + go run . + +# Target for testing the project +.PHONY: test +test: + go test -v ./... + +.PHONY: build-test +build-test: + go build \ + -ldflags "\ + -X $(APP_CFG).TestMode=true \ + -X $(APP_CFG).Version=${CURR_VERSION}-testbuild \ + -X $(APP_CFG).CommitSha=${COMMIT_SHA} \ + -X $(APP_CFG).BuildTime=${BUILD_TIME} \ + " \ + -o $(BINARY_NAME)_testbuild + +.PHONY: test-bats +test-bats: build-test + ./e2e/resize_terminal.sh && bats ./e2e + +# Target for cleaning up the target directory +.PHONY: clean +clean: + rm -rf $(TARGET_DIR) diff --git a/otdfctl/README.md b/otdfctl/README.md new file mode 100644 index 0000000000..15e3c0d592 --- /dev/null +++ b/otdfctl/README.md @@ -0,0 +1,146 @@ +# otdfctl: cli to manage OpenTDF Platform + +This command line interface is used to manage OpenTDF Platform. + +The main goals are to: + +- simplify setup +- facilitate migration +- aid in configuration management + +## TODO list + +- [ ] Add support for json input as piped input +- [ ] Add help level handler for each command +- [ ] Add support for `--verbose` persistent flag +- [ ] Helper functions to support common tasks like pretty printing and json output + +## Usage + +The CLI is configured via profiles. Use `otdfctl profile create ` (and optionally `--set-default`) to define how the CLI should connect to your platform instance. + +Load up the platform (see its [README](https://github.com/opentdf/platform?tab=readme-ov-file#run) for instructions). + +## Development + +### CLI + +The CLI is built using [cobra](https://cobra.dev/). + +The primary function is to support CRUD operations using commands as arguments and flags as the values. + +The output format (currently `styled` or `json`) is stored with each profile (via `otdfctl profile create --output-format ` or `otdfctl profile set-output-format `) and can still be overridden per command with the `--json` flag. + +#### To add a command + +1. Capture the flag value and validate the values + 1. Alt support JSON input as piped input +2. Run the handler which is located in `pkg/handlers` and pass the values as arguments +3. Handle any errors and return the result in a lite TUI format + +### TUI + +> [!CAUTION] +> This is a work in progress please avoid touching until framework is defined + +The TUI will be used to create an interactive experience for the user. + +## Documentation + +Documentation drives the CLI in this project. This can be found in `/docs/man` and is used in the +CLI via the `man.Docs.GetDoc()` function. + +## Testing + +The CLI is equipped with a test mode that can be enabled by building the CLI with `config.TestMode = true`. +For convenience, the CLI can be built with `make build-test`. + +**Test Mode features**: + +- Use the in-memory keyring provider for user profiles +- Enable provisioning profiles for testing via `OTDFCTL_TEST_PROFILE` environment variable + +### BATS + +> [!NOTE] +> Bat Automated Test System (bats) is a TAP-compliant testing framework for Bash. It provides a simple way to verify that the UNIX programs you write behave as expected. + +BATS is used to test the CLI from an end-to-end perspective. To run the tests you will need to ensure the following +prerequisites are met: + +- bats is installed on your system + - Follow bats-core advice [here](https://github.com/bats-core/homebrew-bats-core?tab=readme-ov-file#homebrew-bats-core) +- The platform is running and provisioned with basic keycloak clients/users + - See the [platform README](https://github.com/opentdf/platform) for instructions + +To run the tests you can either run `make test-bats` or execute specific test suites with `bats e2e/.bats`. + +#### Terminal Size + +Some tests for output rendered in the terminal will vary in behavior depending on terminal size. + +Terminal size when testing: + +1. set to standard defaults if running `make test-bats` +2. can be set manually by mouse in terminal where tests are triggered +3. can be set by argument `./e2e/resize_terminal.sh < rows height > < columns width >` +4. can be set by environment variable, i.e. `export TEST_TERMINAL_WIDTH="200"` (200 is columns width) + +#### TestRail Integration (Optional) + +This project supports optional integration with TestRail for uploading BATS test results. + +##### 1. Prerequisites + +- TestRail account with API access enabled +- `jq`, `curl` installed + +##### 2. Setup + +1. Copy and configure TestRail connection: +`cp testrail.config.example.json testrail.config.json` + + Edit `testrail.config.json` with: + - `url`: Your TestRail instance URL + - `projectId`: Your TestRail project ID + - `tapFile`: Path to BATS TAP results file + +2. Copy and configure test mapping: + `cp testname-to-testrail-id.example.json testname-to-testrail-id.json` + +Fill in the mapping between test names (exactly as they appear in TAP output) and your TestRail case IDs. +Can be flat JSON: +```json +{ + "test_name_1": "C12345", + "test_name_2": "C67890" +} +``` +Or nested for better organization: +```json +{ + "group_1": { + "test_name_1": "C12345", + "test_name_2": "C67890" + }, + "group_2": { + "test_name_3": "C54321" + } +} +``` + +3. Set TestRail credentials via environment variables +```bash + export TESTRAIL_USER=you@example.com + export TESTRAIL_PASS=your_api_key +``` + +##### 3. Run Tests and Upload Results + +1. Run BATS with TAP report output (e2e folder): `bats --tap bats-tests/ > e2e/bats-results.tap` +Alternatively, get the TAP test report from the CI pipeline artifacts. +2. Upload results to TestRail: +`TESTRAIL_CLI_RUN_NAME=*optional-testrail-run-name* ./testrail-integration/upload-bats-test-results-to-testrail.sh` + + + diff --git a/otdfctl/adr/0000-use-adr-dir-for-adr.md b/otdfctl/adr/0000-use-adr-dir-for-adr.md new file mode 100644 index 0000000000..2505901830 --- /dev/null +++ b/otdfctl/adr/0000-use-adr-dir-for-adr.md @@ -0,0 +1,44 @@ +--- +status: accepted +date: 2024-08-29 +decision: Use ADRs in the `adr` directory of the repo to document architectural decisions +author: '@jakedoublev' +deciders: ['@ryanulit', '@jrschumacher'] +--- + +# Use a ADR storage format that make diffs easier to read + +## Context and Problem Statement + +We've been using Github Issues to document ADR decisions, but it's hard to read the diffs when changes are made. We need a better way to store and manage ADRs. ADRs sometimes get updated and it's hard to track the changes and decision using the edit history dropdown or the comments section. + +## Decision Drivers + +- **Low barrier of entry**: A primary goal of our ADR process is to ensure decisions are captured. +- **Ease of management**: Make it easy to manage the ADRs. +- **Ensure appropriate tracking and review**: Make it easy to track and review the changes in the ADRs. + +## Considered Options + +1. Use Github Issues +2. Use Github Discussions +3. Use a shared ADR repository +4. Use an `adr` directory in the repo + +## Decision Outcome + +It was decided to use an `adr` directory in the repo to store ADRs. This approach provides a low barrier of entry for developers to document decisions and ensures that the decisions are tracked and reviewed appropriately. + +Additionally, this change does not impact other teams or repositories, and it is easy to manage and maintain. We can experiment with this decision and if it works promote it to other repositories. + +### Consequences + +- **Positive**: + - Low barrier of entry for developers to document decisions. + - Easy to manage and maintain. + - Ensures appropriate tracking and review of decisions via git history and code review. +- **Negative**: + - Requires developers to be aware of the ADR process and where to find the ADRs. + - May require additional tooling to manage and maintain the ADRs. + - May require additional training for developers to understand the ADR process and how to use it effectively. + diff --git a/otdfctl/adr/0001-printing-with-json.md b/otdfctl/adr/0001-printing-with-json.md new file mode 100644 index 0000000000..7f8d835976 --- /dev/null +++ b/otdfctl/adr/0001-printing-with-json.md @@ -0,0 +1,39 @@ +--- +status: accepted +date: 2024-08-29 +decision: Encapsulate printing to ensure consistent output format +author: '@jrschumacher' +deciders: ['@jakedoublev', '@ryanulit', '@suchak1'] +--- + +# Consistent output format for printing JSON and pretty-print + +## Context and Problem Statement + +We need to develop a printer that can globally determine when to print in pretty-print format versus JSON format. This decision is crucial to ensure consistent and appropriate output formatting across different use cases and environments. + +## Decision Drivers + +- **Consistency**: Ensure uniform output format across the application. +- **Flexibility**: Ability to switch between pretty-print and JSON formats based on context. +- **Ease of Implementation**: Simplicity in implementing and maintaining the solution. + +## Considered Options + +1. Keep existing code as is +2. Move the printing into a global function that has context about the CLI flags to drive output format + +## Decision Outcome + +It was decided to encapsulate printing to ensure there is consistent output format. This function will have context about the CLI flags to drive the output format. This approach provides the flexibility to switch between pretty-print and JSON formats based on the context. + +### Consequences + +- **Positive**: + - Provides flexibility to switch formats without changing the code. + - Ensures consistent output format across different environments. + - Simplifies the implementation and maintenance process. + +- **Negative**: + - Requires careful management of configuration settings. + - Potential for misconfiguration leading to incorrect output format when developers use `fmt` directly. diff --git a/otdfctl/cmd/auth/auth.go b/otdfctl/cmd/auth/auth.go new file mode 100644 index 0000000000..69f42012cf --- /dev/null +++ b/otdfctl/cmd/auth/auth.go @@ -0,0 +1,36 @@ +package auth + +import ( + "runtime" + + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/spf13/cobra" +) + +var ( + authCmd = man.Docs.GetCommand("auth", man.WithHiddenFlags( + "with-client-creds", + "with-client-creds-file", + )) + + Cmd = &authCmd.Command +) + +func InitCommands() { + authCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + // not supported on linux + if runtime.GOOS == "linux" { + cli.ExitWithWarning( + "Warning: Keyring storage is not available on Linux. Please use the `--with-client-creds` flag or the" + + "`--with-client-creds-file` flag to provide client credentials securely.", + ) + } + } + + Cmd.AddCommand(newLoginCmd()) + Cmd.AddCommand(newLogoutCmd()) + Cmd.AddCommand(newClientCredentialsCmd()) + Cmd.AddCommand(newClearClientCredentialsCmd()) + Cmd.AddCommand(newPrintAccessTokenCmd()) +} diff --git a/otdfctl/cmd/auth/clearCachedCredentials.go b/otdfctl/cmd/auth/clearCachedCredentials.go new file mode 100644 index 0000000000..05d532af82 --- /dev/null +++ b/otdfctl/cmd/auth/clearCachedCredentials.go @@ -0,0 +1,12 @@ +package auth + +import ( + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/spf13/cobra" +) + +// newClearClientCredentialsCmd creates and configures the clear-client-credentials command. +func newClearClientCredentialsCmd() *cobra.Command { + doc := man.Docs.GetCommand("auth/clear-client-credentials") + return &doc.Command +} diff --git a/otdfctl/cmd/auth/clientCredentials.go b/otdfctl/cmd/auth/clientCredentials.go new file mode 100644 index 0000000000..499779d138 --- /dev/null +++ b/otdfctl/cmd/auth/clientCredentials.go @@ -0,0 +1,78 @@ +package auth + +import ( + "fmt" + "strings" + + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/auth" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/otdfctl/pkg/profiles" + "github.com/spf13/cobra" +) + +func clientCredentialsRun(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + cp := common.InitProfile(c) + + var clientID string + var clientSecret string + + if len(args) > 0 { + clientID = args[0] + } + if len(args) > 1 { + clientSecret = args[1] + } + + if clientID == "" { + clientID = cli.AskForInput("Enter client id: ") + } + if clientSecret == "" { + clientSecret = cli.AskForSecret("Enter client secret: ") + } + var scopes []string + if cmd.Flags().Changed("scopes") { + flagScopes, err := cmd.Flags().GetStringSlice("scopes") + if err != nil { + c.ExitWithError("Failed to read scopes flag", err) + } + scopes = make([]string, 0, len(flagScopes)) + for _, scope := range flagScopes { + scopes = append(scopes, strings.TrimSpace(scope)) + } + } + + // Set the client credentials + err := cp.SetAuthCredentials(profiles.AuthCredentials{ + AuthType: profiles.AuthTypeClientCredentials, + ClientID: clientID, + ClientSecret: clientSecret, + Scopes: scopes, + }) + if err != nil { + c.ExitWithError("Failed to set client credentials", err) + } + + // Validate the client credentials + if err := auth.ValidateProfileAuthCredentials(cmd.Context(), cp); err != nil { + c.ExitWithError("An error occurred during login. Please check your credentials and try again", err) + } + + c.ExitWithMessage(fmt.Sprintf("Client credentials set for profile [%s]", cp.Name()), cli.ExitCodeSuccess) +} + +// newClientCredentialsCmd creates and configures the client-credentials command. +func newClientCredentialsCmd() *cobra.Command { + doc := man.Docs.GetCommand("auth/client-credentials", + man.WithRun(clientCredentialsRun), + man.WithHiddenFlags("with-client-creds", "with-client-creds-file"), + ) + doc.Flags().StringSlice( + doc.GetDocFlag("scopes").Name, + []string{}, + doc.GetDocFlag("scopes").Description, + ) + return &doc.Command +} diff --git a/otdfctl/cmd/auth/login.go b/otdfctl/cmd/auth/login.go new file mode 100644 index 0000000000..c39bbf05f7 --- /dev/null +++ b/otdfctl/cmd/auth/login.go @@ -0,0 +1,66 @@ +package auth + +import ( + "fmt" + + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/auth" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/otdfctl/pkg/profiles" + "github.com/spf13/cobra" +) + +func codeLogin(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + cp := common.InitProfile(c) + clientID := c.FlagHelper.GetRequiredString("client-id") + port := c.FlagHelper.GetOptionalString("port") + tok, err := auth.LoginWithPKCE( + cmd.Context(), + cp.GetEndpoint(), + clientID, + c.FlagHelper.GetOptionalBool("tls-no-verify"), + port, + ) + if err != nil { + c.ExitWithError("could not authenticate", err) + } + + // Set the auth credentials to profile + if err := cp.SetAuthCredentials(profiles.AuthCredentials{ + AuthType: profiles.AuthTypeAccessToken, + AccessToken: profiles.AuthCredentialsAccessToken{ + ClientID: clientID, + AccessToken: tok.AccessToken, + Expiration: tok.Expiry.Unix(), + RefreshToken: tok.RefreshToken, + }, + }); err != nil { + c.ExitWithError("failed to set auth credentials", err) + } + c.ExitWithMessage(fmt.Sprintf("Code login complete for profile: [%s]", cp.Name()), cli.ExitCodeSuccess) +} + +// newLoginCmd creates and configures the login command with all flags. +func newLoginCmd() *cobra.Command { + doc := man.Docs.GetCommand("auth/login", man.WithRun(codeLogin)) + + // Register flags + doc.Flags().StringP( + doc.GetDocFlag("client-id").Name, + doc.GetDocFlag("client-id").Shorthand, + doc.GetDocFlag("client-id").Default, + doc.GetDocFlag("client-id").Description, + ) + + // intentionally a string flag to support an empty port which represents a dynamic port + doc.Flags().StringP( + doc.GetDocFlag("port").Name, + doc.GetDocFlag("port").Shorthand, + doc.GetDocFlag("port").Default, + doc.GetDocFlag("port").Description, + ) + + return &doc.Command +} diff --git a/otdfctl/cmd/auth/logout.go b/otdfctl/cmd/auth/logout.go new file mode 100644 index 0000000000..dda2d0fc7a --- /dev/null +++ b/otdfctl/cmd/auth/logout.go @@ -0,0 +1,42 @@ +package auth + +import ( + "fmt" + + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/auth" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/otdfctl/pkg/profiles" + "github.com/spf13/cobra" +) + +func logout(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + cp := common.InitProfile(c) + + // we can only revoke access tokens stored for the code login flow, not client credentials + creds := cp.GetAuthCredentials() + if creds.AuthType == profiles.AuthTypeAccessToken { + if err := auth.RevokeAccessToken( + cmd.Context(), + cp.GetEndpoint(), + creds.AccessToken.ClientID, + creds.AccessToken.RefreshToken, + c.FlagHelper.GetOptionalBool("tls-no-verify"), + ); err != nil { + c.ExitWithError("An error occurred while revoking the access token", err) + } + } + + if err := cp.SetAuthCredentials(profiles.AuthCredentials{}); err != nil { + c.ExitWithError("An error occurred while logging out", err) + } + c.ExitWithMessage(fmt.Sprintf("Profile: [%s], logged out", cp.Name()), cli.ExitCodeSuccess) +} + +// newLogoutCmd creates and configures the logout command. +func newLogoutCmd() *cobra.Command { + doc := man.Docs.GetCommand("auth/logout", man.WithRun(logout)) + return &doc.Command +} diff --git a/otdfctl/cmd/auth/printAccessToken.go b/otdfctl/cmd/auth/printAccessToken.go new file mode 100644 index 0000000000..aebd504757 --- /dev/null +++ b/otdfctl/cmd/auth/printAccessToken.go @@ -0,0 +1,38 @@ +package auth + +import ( + "fmt" + "os" + + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/auth" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/otdfctl/pkg/profiles" + "github.com/spf13/cobra" +) + +func printAccessTokenRun(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + cp := common.InitProfile(c) + + ac := cp.GetAuthCredentials() + switch ac.AuthType { + case profiles.AuthTypeClientCredentials: + case profiles.AuthTypeAccessToken: + default: + c.ExitWithError("Invalid auth type", nil) + } + tok, err := auth.GetTokenWithProfile(cmd.Context(), cp) + if err != nil { + cli.ExitWithError("Failed to get token", err) + } + + c.ExitWith(fmt.Sprintf("Access Token: %s\n", tok.AccessToken), tok, cli.ExitCodeSuccess, os.Stdout) +} + +// newPrintAccessTokenCmd creates and configures the print-access-token command. +func newPrintAccessTokenCmd() *cobra.Command { + doc := man.Docs.GetCommand("auth/print-access-token", man.WithRun(printAccessTokenRun)) + return &doc.Command +} diff --git a/otdfctl/cmd/common/common.go b/otdfctl/cmd/common/common.go new file mode 100644 index 0000000000..f3532c1387 --- /dev/null +++ b/otdfctl/cmd/common/common.go @@ -0,0 +1,228 @@ +package common + +import ( + "crypto/tls" + "errors" + "fmt" + "log/slog" + + "github.com/evertras/bubble-table/table" + osprofiles "github.com/jrschumacher/go-osprofiles" + "github.com/opentdf/platform/otdfctl/pkg/auth" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/config" + "github.com/opentdf/platform/otdfctl/pkg/handlers" + "github.com/opentdf/platform/otdfctl/pkg/profiles" + "github.com/opentdf/platform/sdk" + "github.com/spf13/cobra" +) + +var profileOutputFormat = profiles.OutputStyled + +func shouldUseProfileJSONOutput() bool { + return profileOutputFormat == profiles.OutputJSON +} + +func applyOutputFormatPreference(c *cli.Cli, store *profiles.OtdfctlProfileStore) { + if store == nil { + return + } + + profileOutputFormat = profiles.NormalizeOutputFormat(store.GetOutputFormat()) + if shouldUseProfileJSONOutput() { + c.SetJSONOutput(true) + } +} + +// InitProfile initializes the profile store and loads the profile specified in the flags +// if onlyNew is set to true, a new profile will be created and returned +// returns the profile and the current profile store +func InitProfile(c *cli.Cli) *profiles.OtdfctlProfileStore { + var err error + profileName := c.FlagHelper.GetOptionalString("profile") + + hasKeyringStore, err := osprofiles.HasGlobalStore(config.AppName, osprofiles.WithKeyringStore()) + if err != nil { + slog.Warn("could not determine whether any profiles were stored on the keyring, defaulting to filesystem", + slog.Any("error", err), + ) + } + if hasKeyringStore { + slog.Debug("keyring store still active, migrating profiles to filesystem") + err := profiles.Migrate(profiles.ProfileDriverFileSystem, profiles.ProfileDriverKeyring) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Error during profile migration from %s, to %s. %s cannot continue with profiles being stored within %s, please use the `profile migrate` command to manually migrate profiles", profiles.ProfileDriverKeyring, profiles.ProfileDriverFileSystem, config.AppName, profiles.ProfileDriverKeyring), err) + } + } + + profiler, err := profiles.CreateProfiler(profiles.ProfileDriverFileSystem) + if err != nil { + cli.ExitWithError("Error creating profiler", err) + } + + defaultProfileName := osprofiles.GetGlobalConfig(profiler).GetDefaultProfile() + if len(defaultProfileName) == 0 { + c.ExitWithWarning(fmt.Sprintf("No default profile set. Use `%s profile create ` to create a default profile.", config.AppName)) + } + + if profileName == "" { + profileName = defaultProfileName + } + + slog.Debug("using profile", slog.String("profile", profileName)) + + // load profile + store, err := profiles.LoadOtdfctlProfileStore(profiles.ProfileDriverFileSystem, profileName) + if err != nil { + c.ExitWithError("Failed to load profile: "+profileName, err) + } + + applyOutputFormatPreference(c, store) + + return store +} + +// instantiates a new handler with authentication via client credentials +// TODO make this a preRun hook +// +//nolint:nestif // separate refactor [https://github.com/opentdf/otdfctl/issues/383] +func NewHandler(c *cli.Cli) handlers.Handler { + // if global flags are set then validate and create a temporary profile in memory + var cp *profiles.OtdfctlProfileStore + + // Non-profile flags + host := c.FlagHelper.GetOptionalString("host") + tlsNoVerify := c.FlagHelper.GetOptionalBool("tls-no-verify") + withClientCreds := c.FlagHelper.GetOptionalString("with-client-creds") + withClientCredsFile := c.FlagHelper.GetOptionalString("with-client-creds-file") + withAccessToken := c.FlagHelper.GetOptionalString("with-access-token") + var inMemoryProfile bool + + authFlags := []string{"--with-access-token", "--with-client-creds", "--with-client-creds-file"} + nonProfileFlags := append([]string{"--host", "--tls-no-verify"}, authFlags...) + hasNonProfileFlags := host != "" || tlsNoVerify || withClientCreds != "" || withClientCredsFile != "" || withAccessToken != "" + + //nolint:nestif // nested if statements are necessary for validation + if hasNonProfileFlags { + err := fmt.Errorf("when using global flags %s, profiles will not be used and all required flags must be set", cli.PrettyList(nonProfileFlags)) + + // host must be set + if host == "" { + cli.ExitWithError("Host must be set", err) + } + + authFlagsCounter := 0 + if withAccessToken != "" { + authFlagsCounter++ + } + if withClientCreds != "" { + authFlagsCounter++ + } + if withClientCredsFile != "" { + authFlagsCounter++ + } + if authFlagsCounter == 0 { + cli.ExitWithError(fmt.Sprintf("One of %s must be set", cli.PrettyList(authFlags)), err) + } else if authFlagsCounter > 1 { + cli.ExitWithError(fmt.Sprintf("Only one of %s must be set", cli.PrettyList(authFlags)), err) + } + + inMemoryProfile = true + config := profiles.ProfileConfig{ + Name: "temp", + Endpoint: host, + TLSNoVerify: tlsNoVerify, + } + cp, err = profiles.NewOtdfctlProfileStore(profiles.ProfileDriverMemory, &config, true) + if err != nil { + cli.ExitWithError("Failed to initialize in-memory profile", err) + } + + // get credentials from flags + if withAccessToken != "" { + claims, err := auth.ParseClaimsJWT(withAccessToken) + if err != nil { + cli.ExitWithError("Failed to get access token", err) + } + + if err := cp.SetAuthCredentials(profiles.AuthCredentials{ + AuthType: profiles.AuthTypeAccessToken, + AccessToken: profiles.AuthCredentialsAccessToken{ + AccessToken: withAccessToken, + Expiration: claims.Expiration, + }, + }); err != nil { + cli.ExitWithError("Failed to set access token", err) + } + } else { + var cc auth.ClientCredentials + if withClientCreds != "" { + cc, err = auth.GetClientCredsFromJSON([]byte(withClientCreds)) + } else if withClientCredsFile != "" { + cc, err = auth.GetClientCredsFromFile(withClientCredsFile) + } + if err != nil { + cli.ExitWithError("Failed to get client credentials", err) + } + + // add credentials to the temporary profile + if err := cp.SetAuthCredentials(profiles.AuthCredentials{ + AuthType: profiles.AuthTypeClientCredentials, + ClientID: cc.ClientID, + ClientSecret: cc.ClientSecret, + Scopes: cc.Scopes, + }); err != nil { + cli.ExitWithError("Failed to set client credentials", err) + } + + applyOutputFormatPreference(c, cp) + } + } else { + cp = InitProfile(c) + } + + if err := auth.ValidateProfileAuthCredentials(c.Context(), cp); err != nil { + endpoint := cp.GetEndpoint() + var certErr *tls.CertificateVerificationError + if errors.As(err, &certErr) { + cli.ExitWithError(fmt.Sprintf("Failed to validate TLS certificates served at '%s'. Caution: if host is correct and insecure certificates should be dangerously trusted, use '--tls-no-verify'", endpoint), nil) + } + if errors.Is(err, sdk.ErrPlatformUnreachable) { + cli.ExitWithError(fmt.Sprintf("Failed to connect to the platform. Is the platform accepting connections at '%s'?", endpoint), nil) + } + if errors.Is(err, sdk.ErrPlatformConfigFailed) { + cli.ExitWithError(fmt.Sprintf("Failed to get the platform configuration. Is the platform serving a well-known configuration at '%s'?", endpoint), nil) + } + if inMemoryProfile { + cli.ExitWithError("Failed to authenticate with flag-provided client credentials.", err) + } + if errors.Is(err, auth.ErrProfileCredentialsNotFound) { + cli.ExitWithWarning("Profile missing credentials. Please login or add client credentials.") + } + + if errors.Is(err, auth.ErrAccessTokenExpired) { + cli.ExitWithWarning("Access token expired. Please login or add flag-provided credentials.") + } + if errors.Is(err, auth.ErrAccessTokenNotFound) { + cli.ExitWithWarning("No access token found. Please login or add flag-provided credentials.") + } + cli.ExitWithError("Failed to get access token.", err) + } + + h, err := handlers.New(handlers.WithProfile(cp)) + if err != nil { + cli.ExitWithError("Unexpected error", err) + } + return h +} + +// HandleSuccess prints a success message according to the configured format (styled table or JSON) +func HandleSuccess(command *cobra.Command, id string, t table.Model, policyObject interface{}) { + c := cli.New(command, []string{}) + jsonFlag := c.Flags.GetOptionalBool("json") + if jsonFlag || shouldUseProfileJSONOutput() { + c.SetJSONOutput(true) + c.ExitWithJSON(policyObject, cli.ExitCodeSuccess) + } + cli.PrintSuccessTable(command, id, t) +} diff --git a/otdfctl/cmd/config/config.go b/otdfctl/cmd/config/config.go new file mode 100644 index 0000000000..73b2b01c05 --- /dev/null +++ b/otdfctl/cmd/config/config.go @@ -0,0 +1,28 @@ +package config + +import ( + "github.com/opentdf/platform/otdfctl/pkg/man" +) + +var ( + outputDoc = man.Docs.GetCommand("config/output") + configDoc = man.Docs.GetCommand("config", man.WithSubcommands(outputDoc)) + Cmd = &configDoc.Command +) + +const ( + cfgDeprecationNotice = "use profile commands" + cfgOutputDeprecationMsg = "use profile set-output-format instead" +) + +func InitCommands() { + // Mark the entire config command as deprecated so users migrate to profiles. + Cmd.Deprecated = cfgDeprecationNotice + outputDoc.Deprecated = cfgOutputDeprecationMsg + + outputDoc.Flags().String( + outputDoc.GetDocFlag("format").Name, + outputDoc.GetDocFlag("format").Default, + outputDoc.GetDocFlag("format").Description, + ) +} diff --git a/otdfctl/cmd/dev/dev.go b/otdfctl/cmd/dev/dev.go new file mode 100644 index 0000000000..d80bdd9dde --- /dev/null +++ b/otdfctl/cmd/dev/dev.go @@ -0,0 +1,66 @@ +//nolint:forbidigo // print statements need flexibility +package dev + +import ( + "fmt" + + "github.com/evertras/bubble-table/table" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/spf13/cobra" +) + +var ( + // Command holding playground-style development + devCmd = man.Docs.GetCommand("dev") + + Cmd = &devCmd.Command +) + +func designSystemRun(cmd *cobra.Command, args []string) { + fmt.Print("Design system\n=============\n\n") + + printDSComponent("Table", renderDSTable()) + + printDSComponent("Messages", renderDSMessages()) +} + +func printDSComponent(title string, component string) { + fmt.Printf("%s\n", title) + fmt.Print("-----\n\n") + fmt.Printf("%s\n", component) + fmt.Print("\n\n") +} + +func renderDSTable() string { + tbl := cli.NewTable( + table.NewFlexColumn("one", "One", cli.FlexColumnWidthOne), + table.NewFlexColumn("two", "Two", cli.FlexColumnWidthOne), + table.NewFlexColumn("three", "Three", cli.FlexColumnWidthOne), + ).WithRows([]table.Row{ + table.NewRow(table.RowData{ + "one": "1", + "two": "2", + "three": "3", + }), + table.NewRow(table.RowData{ + "one": "4", + "two": "5", + "three": "6", + }), + }) + return tbl.View() +} + +func renderDSMessages() string { + return cli.SuccessMessage("Success message") + "\n" + cli.ErrorMessage("Error message", nil) +} + +func InitCommands() { + designCmd := man.Docs.GetCommand("dev/design-system", + man.WithRun(designSystemRun), + ) + devCmd.AddCommand(&designCmd.Command) + + initSelectorsCommands() +} diff --git a/otdfctl/cmd/dev/selectors.go b/otdfctl/cmd/dev/selectors.go new file mode 100644 index 0000000000..a7a7ed9057 --- /dev/null +++ b/otdfctl/cmd/dev/selectors.go @@ -0,0 +1,97 @@ +package dev + +import ( + "fmt" + + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/handlers" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/spf13/cobra" +) + +var selectors []string + +func selectorsGen(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + subject := c.Flags.GetRequiredString("subject") + + flattened, err := handlers.FlattenSubjectContext(subject) + if err != nil { + cli.ExitWithError("Failed to parse subject context keys and values", err) + } + + rows := [][]string{} + for _, item := range flattened { + rows = append(rows, []string{item.Key, fmt.Sprintf("%v", item.Value)}) + } + + t := cli.NewTabular(rows...) + cli.PrintSuccessTable(cmd, "", t) +} + +func selectorsTest(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + subject := c.Flags.GetRequiredString("subject") + selectors = c.Flags.GetStringSlice("selector", selectors, cli.FlagsStringSliceOptions{Min: 1}) + + flattened, err := handlers.FlattenSubjectContext(subject) + if err != nil { + cli.ExitWithError("Failed to process subject context keys and values", err) + } + + rows := [][]string{} + for _, item := range flattened { + for _, selector := range selectors { + if selector == item.Key { + rows = append(rows, []string{item.Key, fmt.Sprintf("%v", item.Value)}) + } + } + } + + t := cli.NewTabular(rows...) + cli.PrintSuccessTable(cmd, "", t) +} + +// initSelectorsCommands sets up the selectors subcommand and its children. +// Called from dev.go InitCommands. +func initSelectorsCommands() { + genCmd := man.Docs.GetCommand("dev/selectors/generate", + man.WithRun(selectorsGen), + ) + genCmd.Flags().StringP( + genCmd.GetDocFlag("subject").Name, + genCmd.GetDocFlag("subject").Shorthand, + genCmd.GetDocFlag("subject").Default, + genCmd.GetDocFlag("subject").Description, + ) + + testCmd := man.Docs.GetCommand("dev/selectors/test", + man.WithRun(selectorsTest), + ) + testCmd.Flags().StringP( + testCmd.GetDocFlag("subject").Name, + testCmd.GetDocFlag("subject").Shorthand, + testCmd.GetDocFlag("subject").Default, + testCmd.GetDocFlag("subject").Description, + ) + testCmd.Flags().StringSliceVarP( + &selectors, + testCmd.GetDocFlag("selector").Name, + testCmd.GetDocFlag("selector").Shorthand, + []string{}, + testCmd.GetDocFlag("selector").Description, + ) + + devSelectors := man.Docs.GetCommand("dev/selectors", + man.WithSubcommands(genCmd, testCmd), + ) + + Cmd.AddCommand(&devSelectors.Command) +} diff --git a/otdfctl/cmd/execute.go b/otdfctl/cmd/execute.go new file mode 100644 index 0000000000..32dc8ec7c3 --- /dev/null +++ b/otdfctl/cmd/execute.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "errors" + "os" + + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/spf13/cobra" +) + +type ExecuteConfig struct { + mountTo *cobra.Command + renameCmd *cobra.Command + cmdName string +} +type ExecuteOptFunc func(c ExecuteConfig) ExecuteConfig + +func WithMountTo(cmd *cobra.Command, renameCmd *cobra.Command) ExecuteOptFunc { + if cmd == nil { + panic("cmd is nil") + } + + return func(c ExecuteConfig) ExecuteConfig { + c.cmdName = cmd.Use + if renameCmd.Use != "" { + c.cmdName = renameCmd.Use + } + c.mountTo = cmd + c.renameCmd = renameCmd + return c + } +} + +func Execute(opts ...ExecuteOptFunc) { + c := ExecuteConfig{} + for _, opt := range opts { + c = opt(c) + } + + if c.mountTo != nil { + err := MountRoot(c.mountTo, c.renameCmd) + if err != nil { + os.Exit(cli.ExitCodeError) + } + } else { + err := RootCmd.Execute() + if err != nil { + os.Exit(cli.ExitCodeError) + } + } +} + +func MountRoot(newRoot *cobra.Command, cmd *cobra.Command) error { + if newRoot == nil { + return errors.New("newRoot is nil") + } + + if cmd != nil { + RootCmd.Use = cmd.Use + RootCmd.Short = cmd.Short + RootCmd.Long = cmd.Long + } + + newRoot.AddCommand(RootCmd) + return nil +} diff --git a/otdfctl/cmd/execute_test.go b/otdfctl/cmd/execute_test.go new file mode 100644 index 0000000000..770649ad12 --- /dev/null +++ b/otdfctl/cmd/execute_test.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "os" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_MountRoot(t *testing.T) { + r, w, _ := os.Pipe() + origStdout := os.Stdout + os.Stdout = w + + rootCmd := cobra.Command{ + Use: "new-root", + Short: "new-root short", + Long: "new-root long", + } + + err := MountRoot(&rootCmd, nil) + require.NoError(t, err) + + assert.Equal(t, "new-root", rootCmd.Use) + assert.Equal(t, "new-root short", rootCmd.Short) + assert.Equal(t, "new-root long", rootCmd.Long) + + err = rootCmd.Execute() + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := r.Read(buf) + require.NoError(t, err) + + // Ensure the old root is added with the existing name + assert.Contains(t, string(buf[:n]), "otdfctl") + + os.Stdout = origStdout +} + +func Test_MountRootWithRename(t *testing.T) { + r, w, _ := os.Pipe() + origStdout := os.Stdout + os.Stdout = w + + rootCmd := cobra.Command{ + Use: "new-root", + Short: "new-root short", + Long: "new-root long", + } + + err := MountRoot(&rootCmd, &cobra.Command{ + Use: "rename-otdfctl", + Short: "rename-otdfctl short", + Long: "rename-otdfctl long", + }) + require.NoError(t, err) + + assert.Equal(t, "new-root", rootCmd.Use) + assert.Equal(t, "new-root short", rootCmd.Short) + assert.Equal(t, "new-root long", rootCmd.Long) + + err = rootCmd.Execute() + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := r.Read(buf) + require.NoError(t, err) + + // Ensure the old root is added as a subcommand and renamed + assert.Contains(t, string(buf[:n]), "rename-otdfctl") + + os.Stdout = origStdout +} + +func Test_MountRootError(t *testing.T) { + require.Error(t, MountRoot(nil, nil)) + require.Error(t, MountRoot(nil, &cobra.Command{ + Use: "rename-otdfctl", + Short: "rename-otdfctl short", + Long: "rename-otdfctl long", + })) +} diff --git a/otdfctl/cmd/interactive.go b/otdfctl/cmd/interactive.go new file mode 100644 index 0000000000..105c8458e2 --- /dev/null +++ b/otdfctl/cmd/interactive.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/otdfctl/tui" + "github.com/spf13/cobra" +) + +// newInteractiveCmd creates and configures the interactive command. +func newInteractiveCmd() *cobra.Command { + doc := man.Docs.GetCommand("interactive", + man.WithRun(func(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + //nolint:errcheck // error does not need to be checked + tui.StartTea(h) + }), + ) + return &doc.Command +} diff --git a/otdfctl/cmd/migrate/migrate.go b/otdfctl/cmd/migrate/migrate.go new file mode 100644 index 0000000000..a320a3d688 --- /dev/null +++ b/otdfctl/cmd/migrate/migrate.go @@ -0,0 +1,36 @@ +package migrate + +import ( + "github.com/opentdf/platform/otdfctl/cmd/migrate/prune" + "github.com/opentdf/platform/otdfctl/pkg/man" +) + +var ( + migrateDoc = man.Docs.GetDoc("migrate") + + Cmd = &migrateDoc.Command +) + +func InitCommands() { + Cmd.PersistentFlags().BoolP( + migrateDoc.GetDocFlag("commit").Name, + migrateDoc.GetDocFlag("commit").Shorthand, + migrateDoc.GetDocFlag("commit").DefaultAsBool(), + migrateDoc.GetDocFlag("commit").Description, + ) + + Cmd.PersistentFlags().BoolP( + migrateDoc.GetDocFlag("interactive").Name, + migrateDoc.GetDocFlag("interactive").Shorthand, + migrateDoc.GetDocFlag("interactive").DefaultAsBool(), + migrateDoc.GetDocFlag("interactive").Description, + ) + + prune.InitCommands() + + Cmd.AddCommand( + migrateNamespacedPolicyCmd(), + prune.Cmd, + newRegisteredResourcesCmd(), // TODO: Put this under a scope once we get there. + ) +} diff --git a/otdfctl/cmd/migrate/namespaced_policy.go b/otdfctl/cmd/migrate/namespaced_policy.go new file mode 100644 index 0000000000..dea26fd325 --- /dev/null +++ b/otdfctl/cmd/migrate/namespaced_policy.go @@ -0,0 +1,98 @@ +package migrate + +import ( + "encoding/json" + "os" + "path/filepath" + + otdfctl "github.com/opentdf/platform/otdfctl/cmd/common" + namespacedpolicy "github.com/opentdf/platform/otdfctl/migrations/namespacedpolicy" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/spf13/cobra" +) + +func migrateNamespacedPolicyCmd() *cobra.Command { + doc := man.Docs.GetCommand("migrate/namespaced-policy", man.WithRun(migrateNamespacedPolicy)) + doc.Args = cobra.NoArgs + doc.Hidden = true + doc.Flags().StringP( + doc.GetDocFlag("scope").Name, + doc.GetDocFlag("scope").Shorthand, + doc.GetDocFlag("scope").Default, + doc.GetDocFlag("scope").Description, + ) + doc.Flags().StringP( + doc.GetDocFlag("output").Name, + doc.GetDocFlag("output").Shorthand, + doc.GetDocFlag("output").Default, + doc.GetDocFlag("output").Description, + ) + + return &doc.Command +} + +func migrateNamespacedPolicy(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + scopeCSV := c.Flags.GetRequiredString("scope") + outputPath := c.Flags.GetRequiredString("output") + + commit, err := cmd.InheritedFlags().GetBool("commit") + if err != nil { + cli.ExitWithError("could not read --commit flag", err) + } + interactive, err := cmd.InheritedFlags().GetBool("interactive") + if err != nil { + cli.ExitWithError("could not read --interactive flag", err) + } + + h := otdfctl.NewHandler(c) + defer h.Close() + + var plannerOpts []namespacedpolicy.Option + if interactive { + plannerOpts = append(plannerOpts, namespacedpolicy.WithInteractiveReviewer(namespacedpolicy.NewHuhInteractiveReviewer(&h, nil))) + } + + planner, err := namespacedpolicy.NewPlanner(&h, scopeCSV, plannerOpts...) + if err != nil { + cli.ExitWithError("could not create namespaced-policy planner", err) + } + + plan, err := planner.Plan(cmd.Context()) + if err != nil { + cli.ExitWithError("could not build namespaced-policy plan", err) + } + + if commit { + executor, err := namespacedpolicy.NewExecutor(h) + if err != nil { + cli.ExitWithError("could not create namespaced-policy executor", err) + } + + if err := executor.Execute(cmd.Context(), plan); err != nil { + cli.ExitWithError("could not execute namespaced-policy commit", err) + } + } + + if err := writeNamespacedPolicyPlan(outputPath, plan); err != nil { + cli.ExitWithError("could not write namespaced-policy plan", err) + } +} + +func writeNamespacedPolicyPlan(path string, plan *namespacedpolicy.Plan) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + + return encoder.Encode(plan) +} diff --git a/otdfctl/cmd/migrate/prune/namespacedPolicy.go b/otdfctl/cmd/migrate/prune/namespacedPolicy.go new file mode 100644 index 0000000000..f92bd68c3c --- /dev/null +++ b/otdfctl/cmd/migrate/prune/namespacedPolicy.go @@ -0,0 +1,33 @@ +package prune + +import ( + "errors" + + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/spf13/cobra" +) + +func pruneNamespacedPolicyCmd() *cobra.Command { + doc := man.Docs.GetCommand("migrate/prune/namespaced-policy", man.WithRun(pruneNamespacedPolicy)) + doc.Args = cobra.NoArgs + doc.Hidden = true + doc.Flags().StringP( + doc.GetDocFlag("scope").Name, + doc.GetDocFlag("scope").Shorthand, + doc.GetDocFlag("scope").Default, + doc.GetDocFlag("scope").Description, + ) + + return &doc.Command +} + +func pruneNamespacedPolicy(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + c.Flags.GetRequiredString("scope") + + cli.ExitWithError( + "migrate prune namespaced-policy is not implemented", + errors.New("the migrate prune namespaced-policy workflow is not implemented yet"), + ) +} diff --git a/otdfctl/cmd/migrate/prune/prune.go b/otdfctl/cmd/migrate/prune/prune.go new file mode 100644 index 0000000000..be2b45dc74 --- /dev/null +++ b/otdfctl/cmd/migrate/prune/prune.go @@ -0,0 +1,16 @@ +package prune + +import ( + "github.com/opentdf/platform/otdfctl/pkg/man" +) + +var ( + pruneDoc = man.Docs.GetCommand("migrate/prune") + + Cmd = &pruneDoc.Command +) + +func InitCommands() { + Cmd.Hidden = true + Cmd.AddCommand(pruneNamespacedPolicyCmd()) +} diff --git a/otdfctl/cmd/migrate/registeredResources.go b/otdfctl/cmd/migrate/registeredResources.go new file mode 100644 index 0000000000..f54d0da1cf --- /dev/null +++ b/otdfctl/cmd/migrate/registeredResources.go @@ -0,0 +1,38 @@ +package migrate + +import ( + otdfctl "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/migrations" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/spf13/cobra" +) + +func newRegisteredResourcesCmd() *cobra.Command { + return &cobra.Command{ + Use: "registered-resources", + Short: "Legacy registered resource migration", + Hidden: true, + Args: cobra.NoArgs, + Run: runRegisteredResources, + } +} + +func runRegisteredResources(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := otdfctl.NewHandler(c) + defer h.Close() + + commit, err := cmd.InheritedFlags().GetBool("commit") + if err != nil { + cli.ExitWithError("could not read --commit flag", err) + } + + interactive, err := cmd.InheritedFlags().GetBool("interactive") + if err != nil { + cli.ExitWithError("could not read --interactive flag", err) + } + + if err := migrations.MigrateRegisteredResources(cmd.Context(), h, &migrations.HuhPrompter{}, commit, interactive); err != nil { + cli.ExitWithError("could not migrate registered resources", err) + } +} diff --git a/otdfctl/cmd/policy/actions.go b/otdfctl/cmd/policy/actions.go new file mode 100644 index 0000000000..ef4fadf8d1 --- /dev/null +++ b/otdfctl/cmd/policy/actions.go @@ -0,0 +1,274 @@ +package policy + +import ( + "fmt" + + "github.com/evertras/bubble-table/table" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/spf13/cobra" +) + +func policyGetAction(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetOptionalID("id") + name := c.Flags.GetOptionalString("name") + // TODO: switch to required namespace if id not provided once namespacing is required by policy + namespace := c.Flags.GetOptionalString("namespace") + + if id == "" && name == "" { + cli.ExitWithError("Either 'id' or 'name' must be provided", nil) + } + + action, err := h.GetAction(cmd.Context(), id, name, namespace) + if err != nil { + identifier := "id: " + id + if id == "" { + identifier = "name: " + name + } + errMsg := fmt.Sprintf("Failed to find action (%s)", identifier) + cli.ExitWithError(errMsg, err) + } + + rows := [][]string{ + {"Id", action.GetId()}, + {"Name", action.GetName()}, + {"Namespace", action.GetNamespace().GetFqn()}, + } + if mdRows := getMetadataRows(action.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, action.GetId(), t, action) +} + +func policyListActions(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + namespace := c.Flags.GetOptionalString("namespace") + + resp, err := h.ListActions(cmd.Context(), limit, offset, namespace) + if err != nil { + cli.ExitWithError("Failed to list actions", err) + } + t := cli.NewTable( + cli.NewUUIDColumn(), + table.NewFlexColumn("name", "Name", cli.FlexColumnWidthFour), + table.NewFlexColumn("action_type", "Action Type", cli.FlexColumnWidthFour), + table.NewFlexColumn("namespace", "Namespace", cli.FlexColumnWidthFour), + ) + rows := []table.Row{} + for _, a := range resp.GetActionsStandard() { + rows = append(rows, table.NewRow(table.RowData{ + "id": a.GetId(), + "action_type": "standard", + "name": a.GetName(), + "namespace": a.GetNamespace().GetFqn(), + })) + } + + for _, a := range resp.GetActionsCustom() { + rows = append(rows, table.NewRow(table.RowData{ + "id": a.GetId(), + "action_type": "custom", + "name": a.GetName(), + "namespace": a.GetNamespace().GetFqn(), + })) + } + + t = t.WithRows(rows) + t = cli.WithListPaginationFooter(t, resp.GetPagination()) + common.HandleSuccess(cmd, "", t, resp) +} + +func policyCreateAction(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + name := c.Flags.GetRequiredString("name") + namespace := c.Flags.GetOptionalString("namespace") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + action, err := h.CreateAction(cmd.Context(), name, namespace, getMetadataMutable(metadataLabels)) + if err != nil { + cli.ExitWithError("Failed to create action", err) + } + + rows := [][]string{ + {"Id", action.GetId()}, + {"Name", action.GetName()}, + {"Namespace", action.GetNamespace().GetFqn()}, + } + + if mdRows := getMetadataRows(action.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, action.GetId(), t, action) +} + +func policyDeleteAction(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + force := c.Flags.GetOptionalBool("force") + ctx := cmd.Context() + + action, err := h.GetAction(ctx, id, "", "") + if err != nil { + errMsg := fmt.Sprintf("Failed to find action (%s)", id) + cli.ExitWithError(errMsg, err) + } + + cli.ConfirmAction(cli.ActionDelete, "action", id, force) + + err = h.DeleteAction(ctx, id) + if err != nil { + errMsg := fmt.Sprintf("Failed to delete action (%s)", id) + cli.ExitWithError(errMsg, err) + } + rows := [][]string{ + {"Id", id}, + {"Name", action.GetName()}, + {"Namespace", action.GetNamespace().GetFqn()}, + } + if mdRows := getMetadataRows(action.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, action) +} + +func policyUpdateAction(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + name := c.Flags.GetOptionalString("name") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + updated, err := h.UpdateAction( + cmd.Context(), + id, + name, + getMetadataMutable(metadataLabels), + getMetadataUpdateBehavior(), + ) + if err != nil { + cli.ExitWithError("Failed to update action", err) + } + rows := [][]string{ + {"Id", id}, + {"Name", updated.GetName()}, + {"Namespace", updated.GetNamespace().GetFqn()}, + } + if mdRows := getMetadataRows(updated.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + + common.HandleSuccess(cmd, id, t, updated) +} + +func injectNamespaceFlag(doc *man.Doc) { + doc.Flags().StringP( + doc.GetDocFlag("namespace").Name, + doc.GetDocFlag("namespace").Shorthand, + doc.GetDocFlag("namespace").Default, + doc.GetDocFlag("namespace").Description, + ) +} + +func initActionsCommands() { + getDoc := man.Docs.GetCommand("policy/actions/get", + man.WithRun(policyGetAction), + ) + getDoc.Flags().StringP( + getDoc.GetDocFlag("id").Name, + getDoc.GetDocFlag("id").Shorthand, + getDoc.GetDocFlag("id").Default, + getDoc.GetDocFlag("id").Description, + ) + getDoc.Flags().StringP( + getDoc.GetDocFlag("name").Name, + getDoc.GetDocFlag("name").Shorthand, + getDoc.GetDocFlag("name").Default, + getDoc.GetDocFlag("name").Description, + ) + injectNamespaceFlag(getDoc) + + listDoc := man.Docs.GetCommand("policy/actions/list", + man.WithRun(policyListActions), + ) + injectNamespaceFlag(listDoc) + injectListPaginationFlags(listDoc) + + createDoc := man.Docs.GetCommand("policy/actions/create", + man.WithRun(policyCreateAction), + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("name").Name, + createDoc.GetDocFlag("name").Shorthand, + createDoc.GetDocFlag("name").Default, + createDoc.GetDocFlag("name").Description, + ) + injectNamespaceFlag(createDoc) + injectLabelFlags(&createDoc.Command, false) + + updateDoc := man.Docs.GetCommand("policy/actions/update", + man.WithRun(policyUpdateAction), + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("id").Name, + updateDoc.GetDocFlag("id").Shorthand, + updateDoc.GetDocFlag("id").Default, + updateDoc.GetDocFlag("id").Description, + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("name").Name, + updateDoc.GetDocFlag("name").Shorthand, + updateDoc.GetDocFlag("name").Default, + updateDoc.GetDocFlag("name").Description, + ) + injectLabelFlags(&updateDoc.Command, true) + + deleteDoc := man.Docs.GetCommand("policy/actions/delete", + man.WithRun(policyDeleteAction), + ) + deleteDoc.Flags().StringP( + deleteDoc.GetDocFlag("id").Name, + deleteDoc.GetDocFlag("id").Shorthand, + deleteDoc.GetDocFlag("id").Default, + deleteDoc.GetDocFlag("id").Description, + ) + deleteDoc.Flags().Bool( + deleteDoc.GetDocFlag("force").Name, + false, + deleteDoc.GetDocFlag("force").Description, + ) + + policyActionsDoc := man.Docs.GetCommand("policy/actions", + man.WithSubcommands( + getDoc, + listDoc, + createDoc, + updateDoc, + deleteDoc, + ), + ) + Cmd.AddCommand(&policyActionsDoc.Command) +} diff --git a/otdfctl/cmd/policy/attributeValues.go b/otdfctl/cmd/policy/attributeValues.go new file mode 100644 index 0000000000..b4b626476f --- /dev/null +++ b/otdfctl/cmd/policy/attributeValues.go @@ -0,0 +1,509 @@ +package policy + +import ( + "fmt" + "math" + + "github.com/evertras/bubble-table/table" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + policycommon "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/spf13/cobra" +) + +var AttributeValuesCmd *cobra.Command + +func createAttributeValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + attrID := c.FlagHelper.GetRequiredID("attribute-id") + value := c.FlagHelper.GetRequiredString("value") + metadataLabels = c.FlagHelper.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + attr, err := h.GetAttribute(ctx, attrID) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to get parent attribute (%s)", attrID), err) + } + + v, err := h.CreateAttributeValue(ctx, attr.GetId(), value, getMetadataMutable(metadataLabels)) + if err != nil { + cli.ExitWithError("Failed to create attribute value", err) + } + + handleValueSuccess(cmd, v) +} + +func getAttributeValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.FlagHelper.GetRequiredID("id") + + v, err := h.GetAttributeValue(cmd.Context(), id) + if err != nil { + cli.ExitWithError("Failed to find attribute value", err) + } + + handleValueSuccess(cmd, v) +} + +func filterValuesByState(values []*policy.Value, state policycommon.ActiveStateEnum) []*policy.Value { + var shouldBeActive bool + switch state { + case policycommon.ActiveStateEnum_ACTIVE_STATE_ENUM_ACTIVE: + shouldBeActive = true + case policycommon.ActiveStateEnum_ACTIVE_STATE_ENUM_INACTIVE: + shouldBeActive = false + case policycommon.ActiveStateEnum_ACTIVE_STATE_ENUM_ANY, + policycommon.ActiveStateEnum_ACTIVE_STATE_ENUM_UNSPECIFIED: + return values + } + + filtered := make([]*policy.Value, 0, len(values)) + for _, v := range values { + if v.GetActive().GetValue() == shouldBeActive { + filtered = append(filtered, v) + } + } + return filtered +} + +func paginateValues(values []*policy.Value, limit, offset int32) ([]*policy.Value, *policy.PageResponse) { + total := len(values) + pagination := &policy.PageResponse{ + Total: int32(min(total, math.MaxInt32)), + CurrentOffset: offset, + } + + off := int(offset) + if off < 0 { + return nil, pagination + } + if off >= total { + return nil, pagination + } + values = values[off:] + + lim := int(limit) + if lim > 0 && lim < len(values) { + values = values[:lim] + pagination.NextOffset = offset + limit + } + + return values, pagination +} + +func listAttributeValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + attrID := c.FlagHelper.GetRequiredID("attribute-id") + state := cli.GetState(cmd) + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + + values, err := h.ListAttributeValues(cmd.Context(), attrID) + if err != nil { + cli.ExitWithError("Failed to list attribute values", err) + } + + filtered := filterValuesByState(values, state) + paged, pagination := paginateValues(filtered, limit, offset) + + t := cli.NewTable( + cli.NewUUIDColumn(), + table.NewFlexColumn("fqn", "Fqn", cli.FlexColumnWidthFour), + table.NewFlexColumn("active", "Active", cli.FlexColumnWidthThree), + table.NewFlexColumn("labels", "Labels", cli.FlexColumnWidthOne), + table.NewFlexColumn("created_at", "Created At", cli.FlexColumnWidthOne), + table.NewFlexColumn("updated_at", "Updated At", cli.FlexColumnWidthOne), + ) + rows := []table.Row{} + for _, val := range paged { + v := cli.GetSimpleAttributeValue(val) + rows = append(rows, table.NewRow(table.RowData{ + "id": v.ID, + "fqn": v.FQN, + "active": v.Active, + "labels": v.Metadata["Labels"], + "created_at": v.Metadata["Created At"], + "updated_at": v.Metadata["Updated At"], + })) + } + t = t.WithRows(rows) + t = cli.WithListPaginationFooter(t, pagination) + + resp := &attributes.ListAttributeValuesResponse{ + Values: paged, + Pagination: pagination, + } + common.HandleSuccess(cmd, "", t, resp) +} + +func updateAttributeValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + id := c.Flags.GetRequiredID("id") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + _, err := h.GetAttributeValue(ctx, id) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to get attribute value (%s)", id), err) + } + + v, err := h.UpdateAttributeValue(ctx, id, getMetadataMutable(metadataLabels), getMetadataUpdateBehavior()) + if err != nil { + cli.ExitWithError("Failed to update attribute value", err) + } + + handleValueSuccess(cmd, v) +} + +func deactivateAttributeValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + id := c.Flags.GetRequiredID("id") + force := c.Flags.GetOptionalBool("force") + + value, err := h.GetAttributeValue(ctx, id) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to get attribute value (%s)", id), err) + } + + cli.ConfirmAction(cli.ActionDeactivate, "attribute value", value.GetValue(), force) + + deactivated, err := h.DeactivateAttributeValue(ctx, id) + if err != nil { + cli.ExitWithError("Failed to deactivate attribute value", err) + } + + handleValueSuccess(cmd, deactivated) +} + +func unsafeReactivateAttributeValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + id := c.Flags.GetRequiredID("id") + + v, err := h.GetAttributeValue(ctx, id) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to get attribute value (%s)", id), err) + } + + if !forceUnsafe { + cli.ConfirmTextInput(cli.ActionReactivate, "attribute value", cli.InputNameFQN, v.GetFqn()) + } + + if reactivated, err := h.UnsafeReactivateAttributeValue(ctx, id); err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to reactivate attribute value (%s)", id), err) + } else { + rows := [][]string{ + {"Id", reactivated.GetId()}, + {"Value", reactivated.GetValue()}, + } + if mdRows := getMetadataRows(v.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, v) + } +} + +func unsafeUpdateAttributeValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + id := c.Flags.GetRequiredID("id") + value := c.Flags.GetOptionalString("value") + + v, err := h.GetAttributeValue(ctx, id) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to get attribute value (%s)", id), err) + } + + if !forceUnsafe { + cli.ConfirmTextInput(cli.ActionUpdateUnsafe, "attribute value", cli.InputNameFQN, v.GetFqn()) + } + + if err := h.UnsafeUpdateAttributeValue(ctx, id, value); err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to update attribute value (%s)", id), err) + } else { + rows := [][]string{ + {"Id", v.GetId()}, + {"Value", value}, + } + if mdRows := getMetadataRows(v.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, v) + } +} + +func unsafeDeleteAttributeValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + id := c.Flags.GetRequiredID("id") + + v, err := h.GetAttributeValue(ctx, id) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to get attribute value (%s)", id), err) + } + + if !forceUnsafe { + cli.ConfirmTextInput(cli.ActionDelete, "attribute value", cli.InputNameFQN, v.GetFqn()) + } + + if err := h.UnsafeDeleteAttributeValue(ctx, id, v.GetFqn()); err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to delete attribute (%s)", id), err) + } else { + rows := [][]string{ + {"Id", v.GetId()}, + {"Value", v.GetValue()}, + {"Deleted", "true"}, + } + if mdRows := getMetadataRows(v.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, v) + } +} + +func policyAssignKeyToAttrValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + value := c.Flags.GetRequiredString("value") + keyID := c.Flags.GetRequiredID("key-id") + + attrKey, err := h.AssignKeyToAttributeValue(c.Context(), value, keyID) + if err != nil { + errMsg := fmt.Sprintf("Failed to assign key: (%s) to attribute value: (%s)", keyID, value) + cli.ExitWithError(errMsg, err) + } + + rows := [][]string{ + {"Value ID", attrKey.GetValueId()}, + {"Key ID", attrKey.GetKeyId()}, + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, value, t, attrKey) +} + +func policyRemoveKeyFromAttrValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + value := c.Flags.GetRequiredString("value") + keyID := c.Flags.GetRequiredID("key-id") + + err := h.RemoveKeyFromAttributeValue(c.Context(), value, keyID) + if err != nil { + errMsg := fmt.Sprintf("Failed to remove key (%s) from attribute value (%s)", keyID, value) + cli.ExitWithError(errMsg, err) + } + + rows := [][]string{ + {"Removed", "true"}, + {"Value", value}, + {"Key ID", keyID}, + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, value, t, nil) +} + +func initAttributeValuesCommands() { + createCmd := man.Docs.GetCommand("policy/attributes/values/create", + man.WithRun(createAttributeValue), + ) + createCmd.Flags().StringP( + createCmd.GetDocFlag("attribute-id").Name, + createCmd.GetDocFlag("attribute-id").Shorthand, + createCmd.GetDocFlag("attribute-id").Default, + createCmd.GetDocFlag("attribute-id").Description, + ) + createCmd.Flags().StringP( + createCmd.GetDocFlag("value").Name, + createCmd.GetDocFlag("value").Shorthand, + createCmd.GetDocFlag("value").Default, + createCmd.GetDocFlag("value").Description, + ) + injectLabelFlags(&createCmd.Command, false) + + getCmd := man.Docs.GetCommand("policy/attributes/values/get", + man.WithRun(getAttributeValue), + ) + getCmd.Flags().StringP( + getCmd.GetDocFlag("id").Name, + getCmd.GetDocFlag("id").Shorthand, + getCmd.GetDocFlag("id").Default, + getCmd.GetDocFlag("id").Description, + ) + + listCmd := man.Docs.GetCommand("policy/attributes/values/list", + man.WithRun(listAttributeValue), + ) + listCmd.Flags().StringP( + listCmd.GetDocFlag("attribute-id").Name, + listCmd.GetDocFlag("attribute-id").Shorthand, + listCmd.GetDocFlag("attribute-id").Default, + listCmd.GetDocFlag("attribute-id").Description, + ) + listCmd.Flags().StringP( + listCmd.GetDocFlag("state").Name, + listCmd.GetDocFlag("state").Shorthand, + listCmd.GetDocFlag("state").Default, + listCmd.GetDocFlag("state").Description, + ) + injectListPaginationFlags(listCmd) + + updateCmd := man.Docs.GetCommand("policy/attributes/values/update", + man.WithRun(updateAttributeValue), + ) + updateCmd.Flags().StringP( + updateCmd.GetDocFlag("id").Name, + updateCmd.GetDocFlag("id").Shorthand, + updateCmd.GetDocFlag("id").Default, + updateCmd.GetDocFlag("id").Description, + ) + injectLabelFlags(&updateCmd.Command, true) + + deactivateCmd := man.Docs.GetCommand("policy/attributes/values/deactivate", + man.WithRun(deactivateAttributeValue), + ) + deactivateCmd.Flags().StringP( + deactivateCmd.GetDocFlag("id").Name, + deactivateCmd.GetDocFlag("id").Shorthand, + deactivateCmd.GetDocFlag("id").Default, + deactivateCmd.GetDocFlag("id").Description, + ) + deactivateCmd.Flags().Bool( + deactivateCmd.GetDocFlag("force").Name, + false, + deactivateCmd.GetDocFlag("force").Description, + ) + + keyCmd := man.Docs.GetCommand("policy/attributes/values/key") + + assignKasKeyCmd := man.Docs.GetCommand("policy/attributes/values/key/assign", + man.WithRun(policyAssignKeyToAttrValue), + ) + assignKasKeyCmd.Flags().StringP( + assignKasKeyCmd.GetDocFlag("value").Name, + assignKasKeyCmd.GetDocFlag("value").Shorthand, + assignKasKeyCmd.GetDocFlag("value").Default, + assignKasKeyCmd.GetDocFlag("value").Description, + ) + assignKasKeyCmd.Flags().StringP( + assignKasKeyCmd.GetDocFlag("key-id").Name, + assignKasKeyCmd.GetDocFlag("key-id").Shorthand, + assignKasKeyCmd.GetDocFlag("key-id").Default, + assignKasKeyCmd.GetDocFlag("key-id").Description, + ) + + removeKasKeyCmd := man.Docs.GetCommand("policy/attributes/values/key/remove", + man.WithRun(policyRemoveKeyFromAttrValue), + ) + removeKasKeyCmd.Flags().StringP( + removeKasKeyCmd.GetDocFlag("value").Name, + removeKasKeyCmd.GetDocFlag("value").Shorthand, + removeKasKeyCmd.GetDocFlag("value").Default, + removeKasKeyCmd.GetDocFlag("value").Description, + ) + removeKasKeyCmd.Flags().StringP( + removeKasKeyCmd.GetDocFlag("key-id").Name, + removeKasKeyCmd.GetDocFlag("key-id").Shorthand, + removeKasKeyCmd.GetDocFlag("key-id").Default, + removeKasKeyCmd.GetDocFlag("key-id").Description, + ) + + unsafeReactivateCmd := man.Docs.GetCommand("policy/attributes/values/unsafe/reactivate", + man.WithRun(unsafeReactivateAttributeValue), + ) + unsafeReactivateCmd.Flags().StringP( + unsafeReactivateCmd.GetDocFlag("id").Name, + unsafeReactivateCmd.GetDocFlag("id").Shorthand, + unsafeReactivateCmd.GetDocFlag("id").Default, + unsafeReactivateCmd.GetDocFlag("id").Description, + ) + + unsafeDeleteCmd := man.Docs.GetCommand("policy/attributes/values/unsafe/delete", + man.WithRun(unsafeDeleteAttributeValue), + ) + unsafeDeleteCmd.Flags().StringP( + unsafeDeleteCmd.GetDocFlag("id").Name, + unsafeDeleteCmd.GetDocFlag("id").Shorthand, + unsafeDeleteCmd.GetDocFlag("id").Default, + unsafeDeleteCmd.GetDocFlag("id").Description, + ) + + unsafeUpdateCmd := man.Docs.GetCommand("policy/attributes/values/unsafe/update", + man.WithRun(unsafeUpdateAttributeValue), + ) + unsafeUpdateCmd.Flags().StringP( + unsafeUpdateCmd.GetDocFlag("id").Name, + unsafeUpdateCmd.GetDocFlag("id").Shorthand, + unsafeUpdateCmd.GetDocFlag("id").Default, + unsafeUpdateCmd.GetDocFlag("id").Description, + ) + unsafeUpdateCmd.Flags().StringP( + unsafeUpdateCmd.GetDocFlag("value").Name, + unsafeUpdateCmd.GetDocFlag("value").Shorthand, + unsafeUpdateCmd.GetDocFlag("value").Default, + unsafeUpdateCmd.GetDocFlag("value").Description, + ) + + unsafeCmd := man.Docs.GetCommand("policy/attributes/values/unsafe") + unsafeCmd.PersistentFlags().BoolVar(&forceUnsafe, + unsafeCmd.GetDocFlag("force").Name, + false, + unsafeCmd.GetDocFlag("force").Description, + ) + + keyCmd.AddSubcommands(assignKasKeyCmd, removeKasKeyCmd) + unsafeCmd.AddSubcommands(unsafeReactivateCmd, unsafeDeleteCmd, unsafeUpdateCmd) + doc := man.Docs.GetCommand("policy/attributes/values", + man.WithSubcommands(createCmd, getCmd, listCmd, updateCmd, deactivateCmd, unsafeCmd, keyCmd), + ) + AttributeValuesCmd = &doc.Command + AttributesCmd.AddCommand(AttributeValuesCmd) +} + +func handleValueSuccess(cmd *cobra.Command, v *policy.Value) { + rows := [][]string{ + {"Id", v.GetId()}, + {"FQN", v.GetFqn()}, + {"Value", v.GetValue()}, + } + if mdRows := getMetadataRows(v.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, v.GetId(), t, v) +} diff --git a/otdfctl/cmd/policy/attributes.go b/otdfctl/cmd/policy/attributes.go new file mode 100644 index 0000000000..6847563f5e --- /dev/null +++ b/otdfctl/cmd/policy/attributes.go @@ -0,0 +1,542 @@ +package policy + +import ( + "fmt" + + "github.com/evertras/bubble-table/table" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/handlers" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/spf13/cobra" +) + +var ( + forceReplaceMetadataLabels bool + attributeValues []string + attributeValuesOrder []string + + AttributesCmd = man.Docs.GetCommand("policy/attributes") +) + +func createAttribute(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + name := c.Flags.GetRequiredString("name") + rule := c.Flags.GetRequiredString("rule") + attributeValues = c.Flags.GetStringSlice("value", attributeValues, cli.FlagsStringSliceOptions{}) + namespace := c.Flags.GetRequiredString("namespace") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + allowTraversal := c.Flags.GetOptionalBoolWrapper("allow-traversal") + + attr, err := h.CreateAttribute(cmd.Context(), name, rule, namespace, attributeValues, getMetadataMutable(metadataLabels), allowTraversal) + if err != nil { + cli.ExitWithError("Failed to create attribute", err) + } + + a := cli.GetSimpleAttribute(attr) + rows := [][]string{ + {"Name", a.Name}, + {"Rule", a.Rule}, + {"Values", cli.CommaSeparated(a.Values)}, + {"Namespace", a.Namespace}, + {"Allow Traversal", a.AllowTraversal}, + } + if mdRows := getMetadataRows(attr.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + + common.HandleSuccess(cmd, a.ID, t, attr) +} + +func getAttribute(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + + attr, err := h.GetAttribute(cmd.Context(), id) + if err != nil { + errMsg := fmt.Sprintf("Failed to get attribute (%s)", id) + cli.ExitWithError(errMsg, err) + } + + a := cli.GetSimpleAttribute(attr) + rows := [][]string{ + {"Id", a.ID}, + {"Name", a.Name}, + {"Rule", a.Rule}, + {"Values", cli.CommaSeparated(a.Values)}, + {"Namespace", a.Namespace}, + {"Allow Traversal", a.AllowTraversal}, + } + if mdRows := getMetadataRows(attr.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, a.ID, t, attr) +} + +func listAttributes(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + state := cli.GetState(cmd) + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + + resp, err := h.ListAttributes(cmd.Context(), state, limit, offset) + if err != nil { + cli.ExitWithError("Failed to list attributes", err) + } + + t := cli.NewTable( + cli.NewUUIDColumn(), + table.NewFlexColumn("namespace", "Namespace", cli.FlexColumnWidthFour), + table.NewFlexColumn("name", "Name", cli.FlexColumnWidthThree), + table.NewFlexColumn("rule", "Rule", cli.FlexColumnWidthTwo), + table.NewFlexColumn("allow_traversal", "Allow Traversal", cli.FlexColumnWidthOne), + table.NewFlexColumn("values", "Values", cli.FlexColumnWidthTwo), + table.NewFlexColumn("active", "Active", cli.FlexColumnWidthTwo), + ) + rows := []table.Row{} + for _, attr := range resp.GetAttributes() { + a := cli.GetSimpleAttribute(attr) + rows = append(rows, table.NewRow(table.RowData{ + "id": a.ID, + "namespace": a.Namespace, + "name": a.Name, + "rule": a.Rule, + "allow_traversal": a.AllowTraversal, + "values": cli.CommaSeparated(a.Values), + "active": a.Active, + })) + } + t = t.WithRows(rows) + t = cli.WithListPaginationFooter(t, resp.GetPagination()) + common.HandleSuccess(cmd, "", t, resp) +} + +func deactivateAttribute(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + id := c.Flags.GetRequiredID("id") + force := c.Flags.GetOptionalBool("force") + + attr, err := h.GetAttribute(ctx, id) + if err != nil { + errMsg := fmt.Sprintf("Failed to get attribute (%s)", id) + cli.ExitWithError(errMsg, err) + } + + cli.ConfirmAction(cli.ActionDeactivate, "attribute", attr.GetName(), force) + + attr, err = h.DeactivateAttribute(ctx, id) + if err != nil { + errMsg := fmt.Sprintf("Failed to deactivate attribute (%s)", id) + cli.ExitWithError(errMsg, err) + } + + a := cli.GetSimpleAttribute(attr) + rows := [][]string{ + {"Name", a.Name}, + {"Rule", a.Rule}, + {"Values", cli.CommaSeparated(a.Values)}, + {"Namespace", a.Namespace}, + {"Allow Traversal", a.AllowTraversal}, + } + if mdRows := getMetadataRows(attr.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, a.ID, t, a) +} + +func updateAttribute(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + if a, err := h.UpdateAttribute(cmd.Context(), id, getMetadataMutable(metadataLabels), getMetadataUpdateBehavior()); err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to update attribute (%s)", id), err) + } else { + rows := [][]string{ + {"Id", a.GetId()}, + {"Name", a.GetName()}, + } + if mdRows := getMetadataRows(a.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, a) + } +} + +func unsafeReactivateAttribute(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + id := c.Flags.GetRequiredID("id") + + a, err := h.GetAttribute(ctx, id) + if err != nil { + errMsg := fmt.Sprintf("Failed to get attribute (%s)", id) + cli.ExitWithError(errMsg, err) + } + + if !forceUnsafe { + cli.ConfirmTextInput(cli.ActionReactivate, "attribute", cli.InputNameFQN, a.GetFqn()) + } + + if reactivatedAttr, err := h.UnsafeReactivateAttribute(ctx, id); err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to reactivate attribute (%s)", id), err) + } else { + rows := [][]string{ + {"Id", reactivatedAttr.GetId()}, + {"Name", reactivatedAttr.GetName()}, + } + if mdRows := getMetadataRows(a.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, a) + } +} + +func unsafeUpdateAttribute(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + id := c.Flags.GetRequiredID("id") + name := c.Flags.GetOptionalString("name") + rule := c.Flags.GetOptionalString("rule") + attributeValuesOrder = c.Flags.GetStringSlice("values-order", attributeValuesOrder, cli.FlagsStringSliceOptions{}) + allowTraversal := c.Flags.GetOptionalBoolWrapper("allow-traversal") + + a, err := h.GetAttribute(ctx, id) + if err != nil { + errMsg := fmt.Sprintf("Failed to get attribute (%s)", id) + cli.ExitWithError(errMsg, err) + } + + if !forceUnsafe { + cli.ConfirmTextInput(cli.ActionUpdateUnsafe, "attribute", cli.InputNameFQN, a.GetFqn()) + } + + updatedAttr, err := h.UnsafeUpdateAttribute(ctx, id, name, rule, attributeValuesOrder, allowTraversal) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to update attribute (%s)", id), err) + } else { + var ( + retrievedVals []string + valueIDs []string + ) + for _, v := range updatedAttr.GetValues() { + retrievedVals = append(retrievedVals, v.GetValue()) + valueIDs = append(valueIDs, v.GetId()) + } + if allowTraversal == nil { + allowTraversal = updatedAttr.GetAllowTraversal() + } + rows := [][]string{ + {"Id", updatedAttr.GetId()}, + {"Name", updatedAttr.GetName()}, + {"Rule", handlers.GetAttributeRuleFromAttributeType(updatedAttr.GetRule())}, + {"Values", cli.CommaSeparated(retrievedVals)}, + {"Value IDs", cli.CommaSeparated(valueIDs)}, + {"Allow Traversal", allowTraversal.String()}, + } + if mdRows := getMetadataRows(updatedAttr.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, updatedAttr) + } +} + +func unsafeDeleteAttribute(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + id := c.Flags.GetRequiredID("id") + + a, err := h.GetAttribute(ctx, id) + if err != nil { + errMsg := fmt.Sprintf("Failed to get attribute (%s)", id) + cli.ExitWithError(errMsg, err) + } + + if !forceUnsafe { + cli.ConfirmTextInput(cli.ActionDelete, "attribute", cli.InputNameFQN, a.GetFqn()) + } + + if err := h.UnsafeDeleteAttribute(ctx, id, a.GetFqn()); err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to delete attribute (%s)", id), err) + } else { + rows := [][]string{ + {"Deleted", "true"}, + {"Id", a.GetId()}, + {"Name", a.GetName()}, + } + if mdRows := getMetadataRows(a.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, a) + } +} + +func policyAssignKeyToAttribute(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + attribute := c.Flags.GetRequiredString("attribute") + keyID := c.Flags.GetRequiredID("key-id") + + // Get the attribute to show meaningful information in case of error + attrKey, err := h.AssignKeyToAttribute(c.Context(), attribute, keyID) + if err != nil { + errMsg := fmt.Sprintf("Failed to assign key: (%s) to attribute: (%s)", keyID, attribute) + cli.ExitWithError(errMsg, err) + } + + // Prepare and display the result + rows := [][]string{ + {"Attribute ID", attrKey.GetAttributeId()}, + {"Key ID", attrKey.GetKeyId()}, + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, attribute, t, attrKey) +} + +func policyRemoveKeyFromAttribute(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + attribute := c.Flags.GetRequiredString("attribute") + keyID := c.Flags.GetRequiredID("key-id") + + err := h.RemoveKeyFromAttribute(c.Context(), attribute, keyID) + if err != nil { + errMsg := fmt.Sprintf("Failed to remove key (%s) from attribute (%s)", keyID, attribute) + cli.ExitWithError(errMsg, err) + } + + // Prepare and display the result + rows := [][]string{ + {"Removed", "true"}, + {"Attribute", attribute}, + {"Key ID", keyID}, + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, attribute, t, nil) +} + +func initAttributesCommands() { + // Create an attribute + createDoc := man.Docs.GetCommand("policy/attributes/create", + man.WithRun(createAttribute), + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("name").Name, + createDoc.GetDocFlag("name").Shorthand, + createDoc.GetDocFlag("name").Default, + createDoc.GetDocFlag("name").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("rule").Name, + createDoc.GetDocFlag("rule").Shorthand, + createDoc.GetDocFlag("rule").Default, + createDoc.GetDocFlag("rule").Description, + ) + createDoc.Flags().StringSliceVarP( + &attributeValues, + createDoc.GetDocFlag("value").Name, + createDoc.GetDocFlag("value").Shorthand, + []string{}, + createDoc.GetDocFlag("value").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("namespace").Name, + createDoc.GetDocFlag("namespace").Shorthand, + createDoc.GetDocFlag("namespace").Default, + createDoc.GetDocFlag("namespace").Description, + ) + createDoc.Flags().Bool( + createDoc.GetDocFlag("allow-traversal").Name, + false, + createDoc.GetDocFlag("allow-traversal").Description, + ) + injectLabelFlags(&createDoc.Command, false) + + // Get an attribute + getDoc := man.Docs.GetCommand("policy/attributes/get", + man.WithRun(getAttribute), + ) + getDoc.Flags().StringP( + getDoc.GetDocFlag("id").Name, + getDoc.GetDocFlag("id").Shorthand, + getDoc.GetDocFlag("id").Default, + getDoc.GetDocFlag("id").Description, + ) + + // List attributes + listDoc := man.Docs.GetCommand("policy/attributes/list", + man.WithRun(listAttributes), + ) + listDoc.Flags().StringP( + listDoc.GetDocFlag("state").Name, + listDoc.GetDocFlag("state").Shorthand, + listDoc.GetDocFlag("state").Default, + listDoc.GetDocFlag("state").Description, + ) + injectListPaginationFlags(listDoc) + + // Update an attribute + updateDoc := man.Docs.GetCommand("policy/attributes/update", + man.WithRun(updateAttribute), + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("id").Name, + updateDoc.GetDocFlag("id").Shorthand, + updateDoc.GetDocFlag("id").Default, + updateDoc.GetDocFlag("id").Description, + ) + injectLabelFlags(&updateDoc.Command, true) + + // Deactivate an attribute + deactivateDoc := man.Docs.GetCommand("policy/attributes/deactivate", + man.WithRun(deactivateAttribute), + ) + deactivateDoc.Flags().StringP( + deactivateDoc.GetDocFlag("id").Name, + deactivateDoc.GetDocFlag("id").Shorthand, + deactivateDoc.GetDocFlag("id").Default, + deactivateDoc.GetDocFlag("id").Description, + ) + deactivateDoc.Flags().Bool( + deactivateDoc.GetDocFlag("force").Name, + false, + deactivateDoc.GetDocFlag("force").Description, + ) + + // unsafe actions on attributes + unsafeCmd := man.Docs.GetCommand("policy/attributes/unsafe") + unsafeCmd.PersistentFlags().BoolVar(&forceUnsafe, + unsafeCmd.GetDocFlag("force").Name, + false, + unsafeCmd.GetDocFlag("force").Description, + ) + + reactivateCmd := man.Docs.GetCommand("policy/attributes/unsafe/reactivate", + man.WithRun(unsafeReactivateAttribute), + ) + reactivateCmd.Flags().StringP( + reactivateCmd.GetDocFlag("id").Name, + reactivateCmd.GetDocFlag("id").Shorthand, + reactivateCmd.GetDocFlag("id").Default, + reactivateCmd.GetDocFlag("id").Description, + ) + deleteCmd := man.Docs.GetCommand("policy/attributes/unsafe/delete", + man.WithRun(unsafeDeleteAttribute), + ) + deleteCmd.Flags().StringP( + deleteCmd.GetDocFlag("id").Name, + deleteCmd.GetDocFlag("id").Shorthand, + deleteCmd.GetDocFlag("id").Default, + deleteCmd.GetDocFlag("id").Description, + ) + unsafeUpdateCmd := man.Docs.GetCommand("policy/attributes/unsafe/update", + man.WithRun(unsafeUpdateAttribute), + ) + unsafeUpdateCmd.Flags().StringP( + unsafeUpdateCmd.GetDocFlag("id").Name, + unsafeUpdateCmd.GetDocFlag("id").Shorthand, + unsafeUpdateCmd.GetDocFlag("id").Default, + unsafeUpdateCmd.GetDocFlag("id").Description, + ) + unsafeUpdateCmd.Flags().StringP( + unsafeUpdateCmd.GetDocFlag("name").Name, + unsafeUpdateCmd.GetDocFlag("name").Shorthand, + unsafeUpdateCmd.GetDocFlag("name").Default, + unsafeUpdateCmd.GetDocFlag("name").Description, + ) + unsafeUpdateCmd.Flags().StringP( + unsafeUpdateCmd.GetDocFlag("rule").Name, + unsafeUpdateCmd.GetDocFlag("rule").Shorthand, + unsafeUpdateCmd.GetDocFlag("rule").Default, + unsafeUpdateCmd.GetDocFlag("rule").Description, + ) + unsafeUpdateCmd.Flags().StringSliceVarP( + &attributeValuesOrder, + unsafeUpdateCmd.GetDocFlag("values-order").Name, + unsafeUpdateCmd.GetDocFlag("values-order").Shorthand, + []string{}, + unsafeUpdateCmd.GetDocFlag("values-order").Description, + ) + unsafeUpdateCmd.Flags().Bool( + unsafeUpdateCmd.GetDocFlag("allow-traversal").Name, + false, + unsafeUpdateCmd.GetDocFlag("allow-traversal").Description, + ) + + keyCmd := man.Docs.GetCommand("policy/attributes/key") + + // Assign KAS key to attribute + assignKasKeyCmd := man.Docs.GetCommand("policy/attributes/key/assign", + man.WithRun(policyAssignKeyToAttribute), + ) + assignKasKeyCmd.Flags().StringP( + assignKasKeyCmd.GetDocFlag("attribute").Name, + assignKasKeyCmd.GetDocFlag("attribute").Shorthand, + assignKasKeyCmd.GetDocFlag("attribute").Default, + assignKasKeyCmd.GetDocFlag("attribute").Description, + ) + assignKasKeyCmd.Flags().StringP( + assignKasKeyCmd.GetDocFlag("key-id").Name, + assignKasKeyCmd.GetDocFlag("key-id").Shorthand, + assignKasKeyCmd.GetDocFlag("key-id").Default, + assignKasKeyCmd.GetDocFlag("key-id").Description, + ) + + removeKasKeyCmd := man.Docs.GetCommand("policy/attributes/key/remove", + man.WithRun(policyRemoveKeyFromAttribute), + ) + removeKasKeyCmd.Flags().StringP( + removeKasKeyCmd.GetDocFlag("attribute").Name, + removeKasKeyCmd.GetDocFlag("attribute").Shorthand, + removeKasKeyCmd.GetDocFlag("attribute").Default, + removeKasKeyCmd.GetDocFlag("attribute").Description, + ) + removeKasKeyCmd.Flags().StringP( + removeKasKeyCmd.GetDocFlag("key-id").Name, + removeKasKeyCmd.GetDocFlag("key-id").Shorthand, + removeKasKeyCmd.GetDocFlag("key-id").Default, + removeKasKeyCmd.GetDocFlag("key-id").Description, + ) + + keyCmd.AddSubcommands(assignKasKeyCmd, removeKasKeyCmd) + unsafeCmd.AddSubcommands(reactivateCmd, deleteCmd, unsafeUpdateCmd) + AttributesCmd.AddSubcommands(createDoc, getDoc, listDoc, updateDoc, deactivateDoc, unsafeCmd, keyCmd) + Cmd.AddCommand(&AttributesCmd.Command) +} diff --git a/otdfctl/cmd/policy/baseKeys.go b/otdfctl/cmd/policy/baseKeys.go new file mode 100644 index 0000000000..b610d6fb54 --- /dev/null +++ b/otdfctl/cmd/policy/baseKeys.go @@ -0,0 +1,172 @@ +package policy + +import ( + "fmt" + + "github.com/evertras/bubble-table/table" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/otdfctl/pkg/utils" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/kasregistry" + "github.com/spf13/cobra" +) + +const ( + kasURIKey = "kas_uri" + kasURIColumn = "Kas URI" + algKey = "algorithm" + algColumn = "Algorithm" + pubPemKey = "public_key_pem" + pubPemColumn = "Public Key PEM" + kasKidKey = "kas_key_id" + kasKidColumn = "Key ID" + isBaseKey = "is_base_key" + isBaseKeyColumn = "Is Base Key" +) + +// KAS Registry Base Keys Command +var policyKasRegistryBaseKeysCmd *cobra.Command + +func getKasKeyIdentifier(c *cli.Cli) (*kasregistry.KasKeyIdentifier, error) { + keyIdentifier := c.Flags.GetRequiredString("key") + kasIdentifier := c.Flags.GetRequiredString("kas") + + identifier := &kasregistry.KasKeyIdentifier{ + Kid: keyIdentifier, + } + + kasInputType := utils.ClassifyString(kasIdentifier) + switch kasInputType { //nolint:exhaustive // default catches unknown + case utils.StringTypeUUID: + identifier.Identifier = &kasregistry.KasKeyIdentifier_KasId{KasId: kasIdentifier} + case utils.StringTypeURI: + identifier.Identifier = &kasregistry.KasKeyIdentifier_Uri{Uri: kasIdentifier} + case utils.StringTypeGeneric: + identifier.Identifier = &kasregistry.KasKeyIdentifier_Name{Name: kasIdentifier} + default: // Catches StringTypeUnknown and any other unexpected types + return nil, fmt.Errorf("invalid KAS identifier: '%s'. Must be a KAS UUID, URI, or Name", kasIdentifier) + } + return identifier, nil +} + +func getBaseKeyTableRows(simpleKey *policy.SimpleKasKey, additionalInfo map[string]string) table.Row { + readableAlg, _ := cli.KeyEnumToAlg(simpleKey.GetPublicKey().GetAlgorithm()) + rowData := table.RowData{ + kasKidKey: simpleKey.GetPublicKey().GetKid(), + pubPemKey: simpleKey.GetPublicKey().GetPem(), + algKey: readableAlg, + kasURIKey: simpleKey.GetKasUri(), + } + + if len(additionalInfo) > 0 { + for key, value := range additionalInfo { + rowData[key] = value + } + } + + return table.NewRow(rowData) +} + +func getBaseKeyTable(additionalColumns []table.Column) table.Model { + columns := []table.Column{ + table.NewFlexColumn(kasURIKey, kasURIColumn, cli.FlexColumnWidthOne), + table.NewFlexColumn(kasKidKey, kasKidColumn, cli.FlexColumnWidthOne), + table.NewFlexColumn(pubPemKey, pubPemColumn, cli.FlexColumnWidthOne), + table.NewFlexColumn(algKey, algColumn, cli.FlexColumnWidthOne), + } + columns = append(columns, additionalColumns...) + + return cli.NewTable( + columns..., + ) +} + +func getBaseKey(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + baseKey, err := h.GetBaseKey(c.Context()) + if err != nil { + cli.ExitWithError("Failed to get base key", err) + } + + if baseKey == nil { + cli.ExitWithError("No base key found", nil) + } + + t := getBaseKeyTable(nil) + t = t.WithRows([]table.Row{getBaseKeyTableRows(baseKey, nil)}) + common.HandleSuccess(cmd, "", t, baseKey) +} + +func setBaseKey(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + var identifier *kasregistry.KasKeyIdentifier + var err error + + id := c.Flags.GetOptionalString("key") + if utils.ClassifyString(id) != utils.StringTypeUUID { + identifier, err = getKasKeyIdentifier(c) + if err != nil { + c.ExitWithError("Invalid key identifier", err) + } + } + baseKey, err := h.SetBaseKey(c.Context(), id, identifier) + if err != nil { + cli.ExitWithError("Failed to set base key", err) + } + + t := getBaseKeyTable([]table.Column{ + table.NewFlexColumn(isBaseKey, isBaseKeyColumn, cli.FlexColumnWidthOne), + }) + + rows := []table.Row{ + getBaseKeyTableRows(baseKey.GetNewBaseKey(), map[string]string{ + isBaseKey: "true", + }), + } + if baseKey.GetPreviousBaseKey() != nil { + rows = append(rows, getBaseKeyTableRows(baseKey.GetPreviousBaseKey(), map[string]string{ + isBaseKey: "false", + })) + } + + t = t.WithRows(rows) + common.HandleSuccess(cmd, "", t, baseKey) +} + +// initBaseKeysCommands sets up the base-keys command and its subcommands. +func initBaseKeysCommands() { + getDoc := man.Docs.GetCommand("policy/kas-registry/key/base/get", + man.WithRun(getBaseKey), + ) + + setDoc := man.Docs.GetCommand("policy/kas-registry/key/base/set", + man.WithRun(setBaseKey), + ) + setDoc.Flags().StringP( + setDoc.GetDocFlag("key").Name, + setDoc.GetDocFlag("key").Shorthand, + setDoc.GetDocFlag("key").Default, + setDoc.GetDocFlag("key").Description, + ) + setDoc.Flags().StringP( + setDoc.GetDocFlag("kas").Name, + setDoc.GetDocFlag("kas").Shorthand, + setDoc.GetDocFlag("kas").Default, + setDoc.GetDocFlag("kas").Description, + ) + + doc := man.Docs.GetCommand("policy/kas-registry/key/base", + man.WithSubcommands(getDoc, setDoc)) + policyKasRegistryBaseKeysCmd = &doc.Command + policyKasRegistryKeysCmd.AddCommand( + policyKasRegistryBaseKeysCmd, + ) +} diff --git a/otdfctl/cmd/policy/kasGrants.go b/otdfctl/cmd/policy/kasGrants.go new file mode 100644 index 0000000000..e25abb159b --- /dev/null +++ b/otdfctl/cmd/policy/kasGrants.go @@ -0,0 +1,289 @@ +package policy + +import ( + "errors" + "fmt" + + "github.com/evertras/bubble-table/table" + "github.com/google/uuid" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/handlers" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/spf13/cobra" +) + +var forceFlagValue = false + +func assignKasGrant(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + cmd.Println(cli.WarningMessage(`Grants are now Key Mappings. To assign a key to attribute definition, value or namespace use the following commands. + + policy attributes namespace key assign + + policy attributes key assign + + policy attributes value key assign + `)) +} + +func unassignKasGrant(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + cmd.Println(cli.WarningMessage(`Grants are now Key Mappings. The unassign grant command will be removed in the next release. + + policy attributes namespace key remove + + policy attributes key remove + + policy attributes value key remove + `)) + + ctx := cmd.Context() + nsID := c.Flags.GetOptionalID("namespace-id") + attrID := c.Flags.GetOptionalID("attribute-id") + valID := c.Flags.GetOptionalID("value-id") + kasID := c.Flags.GetRequiredID("kas-id") + force := c.Flags.GetOptionalBool("force") + + count := 0 + for _, v := range []string{nsID, attrID, valID} { + if v != "" { + count++ + } + } + if count != 1 { + cli.ExitWithError("Must specify exactly one Attribute Namespace ID, Definition ID, or Value ID to unassign", errors.New("invalid flag values")) + } + var ( + res interface{} + err error + confirm string + rowID []string + rowFQN []string + ) + + kas, err := h.GetKasRegistryEntry(ctx, handlers.KasIdentifier{ + ID: kasID, + }) + if err != nil || kas == nil { + cli.ExitWithError("Failed to get registered KAS", err) + } + kasURI := kas.GetUri() + + //nolint:gocritic,nestif // this is more readable than a switch statement + if nsID != "" { + ns, err := h.GetNamespace(ctx, nsID) + if err != nil || ns == nil { + cli.ExitWithError("Failed to get namespace definition", err) + } + confirm = fmt.Sprintf("the grant to namespace FQN (%s) of KAS URI", ns.GetFqn()) + cli.ConfirmAction(cli.ActionDelete, confirm, kasURI, force) + res, err = h.DeleteKasGrantFromNamespace(ctx, nsID, kasID) + if err != nil { + cli.ExitWithError("Failed to update KAS grant for namespace", err) + } + + rowID = []string{"Namespace ID", nsID} + rowFQN = []string{"Namespace FQN", ns.GetFqn()} + } else if attrID != "" { + attr, err := h.GetAttribute(ctx, attrID) + if err != nil || attr == nil { + cli.ExitWithError("Failed to get attribute definition", err) + } + confirm = fmt.Sprintf("the grant to attribute FQN (%s) of KAS URI", attr.GetFqn()) + cli.ConfirmAction(cli.ActionDelete, confirm, kasURI, force) + res, err = h.DeleteKasGrantFromAttribute(ctx, attrID, kasID) + if err != nil { + cli.ExitWithError("Failed to update KAS grant for attribute", err) + } + + rowID = []string{"Attribute ID", attrID} + rowFQN = []string{"Attribute FQN", attr.GetFqn()} + } else { + val, err := h.GetAttributeValue(ctx, valID) + if err != nil || val == nil { + cli.ExitWithError("Failed to get attribute value", err) + } + confirm = fmt.Sprintf("the grant to attribute value FQN (%s) of KAS URI", val.GetFqn()) + cli.ConfirmAction(cli.ActionDelete, confirm, kasURI, force) + _, err = h.DeleteKasGrantFromValue(ctx, valID, kasID) + if err != nil { + cli.ExitWithError("Failed to update KAS grant for attribute value", err) + } + rowID = []string{"Value ID", valID} + rowFQN = []string{"Value FQN", val.GetFqn()} + } + + t := cli.NewTabular(rowID, rowFQN, + []string{"KAS ID", kasID}, + []string{"Unassigned Granted KAS URI", kasURI}, + ) + common.HandleSuccess(cmd, "", t, res) +} + +func listKasGrants(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + cmd.Println(cli.WarningMessage(`Grants are now Key Mappings. The ability to list grants will be removed in the next release.`)) + + kasF := c.Flags.GetOptionalString("kas") + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + var ( + kasID string + kasURI string + ) + + // if not a UUID, infer flag value passed was a URI + if kasF != "" { + _, err := uuid.Parse(kasF) + if err != nil { + kasURI = kasF + } else { + kasID = kasF + } + } + + grants, page, err := h.ListKasGrants(cmd.Context(), kasID, kasURI, limit, offset) + if err != nil { + cli.ExitWithError("Failed to list assigned KAS Grants", err) + } + + rows := []table.Row{} + t := cli.NewTable( + // columns should be kas id, kas uri, type, id, fqn + table.NewFlexColumn("kas_id", "KAS ID", cli.FlexColumnWidthThree), + table.NewFlexColumn("kas_uri", "KAS URI", cli.FlexColumnWidthThree), + table.NewFlexColumn("grant_type", "Assigned To", cli.FlexColumnWidthOne), + table.NewFlexColumn("id", "Granted Object ID", cli.FlexColumnWidthThree), + table.NewFlexColumn("fqn", "Granted Object FQN", cli.FlexColumnWidthThree), + ) + + for _, g := range grants { + grantedKasID := g.GetKeyAccessServer().GetId() + grantedKasURI := g.GetKeyAccessServer().GetUri() + for _, ag := range g.GetAttributeGrants() { + rows = append(rows, table.NewRow(table.RowData{ + "kas_id": grantedKasID, + "kas_uri": grantedKasURI, + "grant_type": "Definition", + "id": ag.GetId(), + "fqn": ag.GetFqn(), + })) + } + for _, vg := range g.GetValueGrants() { + rows = append(rows, table.NewRow(table.RowData{ + "kas_id": grantedKasID, + "kas_uri": grantedKasURI, + "grant_type": "Value", + "id": vg.GetId(), + "fqn": vg.GetFqn(), + })) + } + for _, ng := range g.GetNamespaceGrants() { + rows = append(rows, table.NewRow(table.RowData{ + "kas_id": grantedKasID, + "kas_uri": grantedKasURI, + "grant_type": "Namespace", + "id": ng.GetId(), + "fqn": ng.GetFqn(), + })) + } + } + t = t.WithRows(rows) + t = cli.WithListPaginationFooter(t, page) + + // Do not supporting printing the 'get --id=...' helper message as grants are atypical + // with no individual ID. + cmd.Use = "" + common.HandleSuccess(cmd, "", t, grants) +} + +func initKASGrantsCommands() { + assignCmd := man.Docs.GetCommand("policy/kas-grants/assign", + man.WithRun(assignKasGrant), + ) + assignCmd.Flags().StringP( + assignCmd.GetDocFlag("namespace-id").Name, + assignCmd.GetDocFlag("namespace-id").Shorthand, + assignCmd.GetDocFlag("namespace-id").Default, + assignCmd.GetDocFlag("namespace-id").Description, + ) + assignCmd.Flags().StringP( + assignCmd.GetDocFlag("attribute-id").Name, + assignCmd.GetDocFlag("attribute-id").Shorthand, + assignCmd.GetDocFlag("attribute-id").Default, + assignCmd.GetDocFlag("attribute-id").Description, + ) + assignCmd.Flags().StringP( + assignCmd.GetDocFlag("value-id").Name, + assignCmd.GetDocFlag("value-id").Shorthand, + assignCmd.GetDocFlag("value-id").Default, + assignCmd.GetDocFlag("value-id").Description, + ) + assignCmd.Flags().StringP( + assignCmd.GetDocFlag("kas-id").Name, + assignCmd.GetDocFlag("kas-id").Shorthand, + assignCmd.GetDocFlag("kas-id").Default, + assignCmd.GetDocFlag("kas-id").Description, + ) + injectLabelFlags(&assignCmd.Command, true) + + unassignCmd := man.Docs.GetCommand("policy/kas-grants/unassign", + man.WithRun(unassignKasGrant), + ) + unassignCmd.Flags().StringP( + unassignCmd.GetDocFlag("namespace-id").Name, + unassignCmd.GetDocFlag("namespace-id").Shorthand, + unassignCmd.GetDocFlag("namespace-id").Default, + unassignCmd.GetDocFlag("namespace-id").Description, + ) + unassignCmd.Flags().StringP( + unassignCmd.GetDocFlag("attribute-id").Name, + unassignCmd.GetDocFlag("attribute-id").Shorthand, + unassignCmd.GetDocFlag("attribute-id").Default, + unassignCmd.GetDocFlag("attribute-id").Description, + ) + unassignCmd.Flags().StringP( + unassignCmd.GetDocFlag("value-id").Name, + unassignCmd.GetDocFlag("value-id").Shorthand, + unassignCmd.GetDocFlag("value-id").Default, + unassignCmd.GetDocFlag("value-id").Description, + ) + unassignCmd.Flags().StringP( + unassignCmd.GetDocFlag("kas-id").Name, + unassignCmd.GetDocFlag("kas-id").Shorthand, + unassignCmd.GetDocFlag("kas-id").Default, + unassignCmd.GetDocFlag("kas-id").Description, + ) + unassignCmd.Flags().BoolVar( + &forceFlagValue, + unassignCmd.GetDocFlag("force").Name, + false, + unassignCmd.GetDocFlag("force").Description, + ) + + listCmd := man.Docs.GetCommand("policy/kas-grants/list", + man.WithRun(listKasGrants), + ) + listCmd.Flags().StringP( + listCmd.GetDocFlag("kas").Name, + listCmd.GetDocFlag("kas").Shorthand, + listCmd.GetDocFlag("kas").Default, + listCmd.GetDocFlag("kas").Description, + ) + injectListPaginationFlags(listCmd) + + cmd := man.Docs.GetCommand("policy/kas-grants", + man.WithSubcommands(assignCmd, unassignCmd, listCmd), + ) + Cmd.AddCommand(&cmd.Command) +} diff --git a/otdfctl/cmd/policy/kasKeys.go b/otdfctl/cmd/policy/kasKeys.go new file mode 100644 index 0000000000..5c1c408b62 --- /dev/null +++ b/otdfctl/cmd/policy/kasKeys.go @@ -0,0 +1,1112 @@ +package policy + +import ( + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/evertras/bubble-table/table" + "github.com/opentdf/platform/lib/ocrypto" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/handlers" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/otdfctl/pkg/utils" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/kasregistry" + "github.com/spf13/cobra" +) + +const ( + rsa2048Len = 2048 + rsa4096Len = 4096 + ecSecp256Len = 256 + ecSecp384Len = 384 + ecSecp521Len = 521 + keyStatusActive = "active" + keyStatusRotated = "rotated" + keyModeLocal = "local" + keyModeProvider = "provider" + keyModeRemote = "remote" + keyModePublicKeyOnly = "public_key" +) + +var policyKasRegistryKeysCmd = man.Docs.GetCommand("policy/kas-registry/key") + +func wrapKey(key string, wrappingKey []byte) ([]byte, error) { + aesKey, err := ocrypto.NewAESGcm(wrappingKey) + if err != nil { + return nil, errors.Join(errors.New("failed to create AES key"), err) + } + + wrappedKek, err := aesKey.Encrypt([]byte(key)) + if err != nil { + return nil, errors.Join(errors.New("failed to wrap key"), err) + } + + return wrappedKek, nil +} + +func generateKeys(alg policy.Algorithm) (string, string, error) { + kek, err := generateKeyPair(alg) + if err != nil { + return "", "", errors.Join(errors.New("failed to generate key pair"), err) + } + + kekPrivPem, err := kek.PrivateKeyInPemFormat() + if err != nil { + return "", "", errors.Join(errors.New("failed to get private key in pem format"), err) + } + + kekPubPem, err := kek.PublicKeyInPemFormat() + if err != nil { + return "", "", errors.Join(errors.New("failed to get public key in pem format"), err) + } + + return kekPrivPem, kekPubPem, nil +} + +func generateKeyPair(alg policy.Algorithm) (ocrypto.KeyPair, error) { + var key ocrypto.KeyPair + var err error + switch alg { + case policy.Algorithm_ALGORITHM_RSA_2048: + key, err = generateRSAKey(rsa2048Len) + case policy.Algorithm_ALGORITHM_RSA_4096: + key, err = generateRSAKey(rsa4096Len) + case policy.Algorithm_ALGORITHM_EC_P256: + key, err = generateECCKey(ecSecp256Len) + case policy.Algorithm_ALGORITHM_EC_P384: + key, err = generateECCKey(ecSecp384Len) + case policy.Algorithm_ALGORITHM_EC_P521: + key, err = generateECCKey(ecSecp521Len) + case policy.Algorithm_ALGORITHM_UNSPECIFIED: + fallthrough + default: + return nil, errors.New("unsupported algorithm") + } + + return key, err +} + +func generateRSAKey(size int) (ocrypto.RsaKeyPair, error) { + return ocrypto.NewRSAKeyPair(size) +} + +func generateECCKey(size int) (ocrypto.ECKeyPair, error) { + mode, err := ocrypto.ECSizeToMode(size) + if err != nil { + return ocrypto.ECKeyPair{}, err + } + + return ocrypto.NewECKeyPair(mode) +} + +func enumToStatus(enum policy.KeyStatus) (string, error) { + switch enum { //nolint:exhaustive // UNSPECIFIED is not needed here + case policy.KeyStatus_KEY_STATUS_ACTIVE: + return keyStatusActive, nil + case policy.KeyStatus_KEY_STATUS_ROTATED: + return keyStatusRotated, nil + default: + return "", errors.New("invalid enum status") + } +} + +func enumToMode(enum policy.KeyMode) (string, error) { + switch enum { //nolint:exhaustive // UNSPECIFIED is not needed here + case policy.KeyMode_KEY_MODE_CONFIG_ROOT_KEY: + return keyModeLocal, nil + case policy.KeyMode_KEY_MODE_PROVIDER_ROOT_KEY: + return keyModeProvider, nil + case policy.KeyMode_KEY_MODE_REMOTE: + return keyModeRemote, nil + case policy.KeyMode_KEY_MODE_PUBLIC_KEY_ONLY: + return keyModePublicKeyOnly, nil + default: + return "", errors.New("invalid enum mode") + } +} + +func modeToEnum(mode string) (policy.KeyMode, error) { + switch strings.ToLower(mode) { + case keyModeLocal: + return policy.KeyMode_KEY_MODE_CONFIG_ROOT_KEY, nil + case keyModeProvider: + return policy.KeyMode_KEY_MODE_PROVIDER_ROOT_KEY, nil + case keyModeRemote: + return policy.KeyMode_KEY_MODE_REMOTE, nil + case keyModePublicKeyOnly: + return policy.KeyMode_KEY_MODE_PUBLIC_KEY_ONLY, nil + default: + return policy.KeyMode_KEY_MODE_UNSPECIFIED, errors.New("invalid mode") + } +} + +func getTableRows(kasKey *policy.KasKey) [][]string { + var err error + asymkey := kasKey.GetKey() + + statusStr, err := enumToStatus(asymkey.GetKeyStatus()) + if err != nil { + cli.ExitWithError("Failed to convert status", err) + } + modeStr, err := enumToMode(asymkey.GetKeyMode()) + if err != nil { + cli.ExitWithError("Failed to convert mode", err) + } + algStr, err := cli.KeyEnumToAlg(asymkey.GetKeyAlgorithm()) + if err != nil { + cli.ExitWithError("Failed to convert algorithm", err) + } + + rows := [][]string{ + {"ID", asymkey.GetId()}, + {"KAS URI", kasKey.GetKasUri()}, + {"Key ID", asymkey.GetKeyId()}, + {"Algorithm", algStr}, + {"Status", statusStr}, + {"Mode", modeStr}, + {"Legacy", strconv.FormatBool(asymkey.GetLegacy())}, + } + return rows +} + +// TODO: Handle wrapping the generated key with provider config. +func policyCreateKasKey(cmd *cobra.Command, args []string) { + var wrappingKeyID string + + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + keyIdentifier := c.Flags.GetRequiredString("key-id") + kasIdentifier := c.Flags.GetRequiredString("kas") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + // Use the helper function to get and validate key parameters + alg, mode, wrappingKeyID, err := prepareKeyParams(c) + if err != nil { + cli.ExitWithError("Invalid key parameters", err) + } + + // Use the helper function to prepare key contexts + publicKeyCtx, privateKeyCtx, providerConfigID, err := prepareKeyContexts(c, mode, alg, wrappingKeyID) + if err != nil { + cli.ExitWithError("Failed to prepare key contexts", err) + } + + kasLookup, err := resolveKasIdentifier(kasIdentifier) + if err != nil { + cli.ExitWithError("Invalid kas identifier", err) + } + + var resolvedKasID string + if kasLookup.ID != "" { + resolvedKasID = kasLookup.ID + } else { + // If not a UUID, resolve it to get the UUID + kasEntry, err := h.GetKasRegistryEntry(c.Context(), kasLookup) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to resolve KAS identifier '%s'", kasIdentifier), err) + } + resolvedKasID = kasEntry.GetId() + } + + kasKey, err := h.CreateKasKey( + c.Context(), + resolvedKasID, + keyIdentifier, + alg, + mode, + publicKeyCtx, + privateKeyCtx, + providerConfigID, + getMetadataMutable(metadataLabels), + false, + ) + if err != nil { + cli.ExitWithError("Failed to create kas key", err) + } + + rows := getTableRows(kasKey) + if mdRows := getMetadataRows(kasKey.GetKey().GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, kasKey.GetKey().GetId(), t, kasKey) +} + +func policyImportKasKey(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + privateKeyPem := c.Flags.GetRequiredString("private-key-pem") + wrappingKey := c.Flags.GetRequiredString("wrapping-key") + wrappingKeyID := c.Flags.GetRequiredString("wrapping-key-id") + publicKeyPem := c.Flags.GetRequiredString("public-key-pem") + keyIdentifier := c.Flags.GetRequiredString("key-id") + algorithm := c.Flags.GetRequiredString("algorithm") + kasIdentifier := c.Flags.GetRequiredString("kas") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + legacy, err := getLegacyFlag(c) + if err != nil { + cli.ExitWithError("Invalid legacy flag", err) + } + if legacy == nil { + legacy = new(bool) + *legacy = false + } + + // Parse algorithm early for validation + alg, err := cli.KeyAlgToEnum(algorithm) + if err != nil { + cli.ExitWithError("Invalid algorithm", err) + } + + decodedPub, err := base64.StdEncoding.DecodeString(publicKeyPem) + if err != nil { + cli.ExitWithError("public-key-pem must be base64 encoded", err) + } + if err := validatePublicKey(decodedPub, alg); err != nil { + cli.ExitWithError("Invalid public key pem", err) + } + nonBase64PrivateKey, err := base64.StdEncoding.DecodeString(privateKeyPem) + if err != nil { + cli.ExitWithError("private-key-pem must be base64 encoded", err) + } + + wrappingKeyBytes, err := hex.DecodeString(wrappingKey) + if err != nil { + cli.ExitWithError("wrapping-key must be hex encoded", err) + } + wrappedPrivateKey, err := wrapKey(string(nonBase64PrivateKey), wrappingKeyBytes) + if err != nil { + cli.ExitWithError("failed to wrap key", err) + } + + kasLookup, err := resolveKasIdentifier(kasIdentifier) + if err != nil { + cli.ExitWithError("Invalid kas identifier", err) + } + var resolvedKasID string + if kasLookup.ID != "" { + resolvedKasID = kasLookup.ID + } else { + // If not a UUID, resolve it to get the UUID + kasEntry, err := h.GetKasRegistryEntry(c.Context(), kasLookup) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to resolve KAS identifier '%s'", kasIdentifier), err) + } + resolvedKasID = kasEntry.GetId() + } + + importedKey, err := h.CreateKasKey(c.Context(), + resolvedKasID, + keyIdentifier, + alg, + policy.KeyMode_KEY_MODE_CONFIG_ROOT_KEY, + &policy.PublicKeyCtx{Pem: publicKeyPem}, + &policy.PrivateKeyCtx{ + KeyId: wrappingKeyID, + WrappedKey: base64.StdEncoding.EncodeToString(wrappedPrivateKey), + }, + "", + getMetadataMutable(metadataLabels), + *legacy, + ) + if err != nil { + cli.ExitWithError("Failed to import kas key", err) + } + + rows := getTableRows(importedKey) + if mdRows := getMetadataRows(importedKey.GetKey().GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, importedKey.GetKey().GetId(), t, importedKey) +} + +func policyGetKasKey(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetOptionalString("key") + + var identifier *kasregistry.KasKeyIdentifier + var err error + + if utils.ClassifyString(id) != utils.StringTypeUUID { + identifier, err = getKasKeyIdentifier(c) + if err != nil { + cli.ExitWithError("Invalid key identifier", err) + } + } + kasKey, err := h.GetKasKey(c.Context(), id, identifier) + if err != nil { + cli.ExitWithError("Failed to get kas key", err) + } + + rows := getTableRows(kasKey) + if mdRows := getMetadataRows(kasKey.GetKey().GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, kasKey.GetKey().GetId(), t, kasKey) +} + +func policyUpdateKasKey(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + resp, err := h.UpdateKasKey( + c.Context(), + id, + getMetadataMutable(metadataLabels), + getMetadataUpdateBehavior()) + if err != nil { + cli.ExitWithError("Failed to update kas key", err) + } + + // Get KAS Key. + kasKey, err := h.GetKasKey(c.Context(), resp.GetKey().GetId(), nil) + if err != nil { + cli.ExitWithError("Failed to get kas key", err) + } + + rows := getTableRows(kasKey) + if mdRows := getMetadataRows(kasKey.GetKey().GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, kasKey.GetKey().GetId(), t, kasKey) +} + +func policyListKasKeys(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + algArg := c.Flags.GetOptionalString("algorithm") + var alg policy.Algorithm + if algArg != "" { + var err error + alg, err = cli.KeyAlgToEnum(algArg) + if err != nil { + cli.ExitWithError("Invalid algorithm", err) + } + } + kasIdentifier := c.Flags.GetOptionalString("kas") + legacy, err := getLegacyFlag(c) + if err != nil { + cli.ExitWithError("Invalid legacy flag", err) + } + + kasLookup, err := resolveKasIdentifier(kasIdentifier) + if err != nil { + cli.ExitWithError("Invalid kas identifier", err) + } + + // Get the list of keys. + resp, err := h.ListKasKeys(c.Context(), limit, offset, alg, kasLookup, legacy) + if err != nil { + cli.ExitWithError("Failed to list kas keys", err) + } + + t := cli.NewTable( + // columns should be id, name, config, labels, created_at, updated_at + cli.NewUUIDColumn(), + table.NewFlexColumn("kasUri", "KAS URI", cli.FlexColumnWidthThree), + table.NewFlexColumn("keyId", "Key ID", cli.FlexColumnWidthOne), + table.NewFlexColumn("keyAlgorithm", "Key Algorithm", cli.FlexColumnWidthOne), + table.NewFlexColumn("keyStatus", "Key Status", cli.FlexColumnWidthOne), + table.NewFlexColumn("keyMode", "Key Mode", cli.FlexColumnWidthOne), + table.NewFlexColumn("legacy", "Legacy", cli.FlexColumnWidthOne), + ) + rows := []table.Row{} + for _, kasKey := range resp.GetKasKeys() { + key := kasKey.GetKey() + statusStr, err := enumToStatus(key.GetKeyStatus()) + if err != nil { + cli.ExitWithError("Failed to convert status", err) + } + modeStr, err := enumToMode(key.GetKeyMode()) + if err != nil { + cli.ExitWithError("Failed to convert mode", err) + } + algStr, err := cli.KeyEnumToAlg(key.GetKeyAlgorithm()) + if err != nil { + cli.ExitWithError("Failed to convert algorithm", err) + } + + rows = append(rows, table.NewRow(table.RowData{ + "id": key.GetId(), + "kasUri": kasKey.GetKasUri(), + "keyId": key.GetKeyId(), + "keyAlgorithm": algStr, + "keyStatus": statusStr, + "keyMode": modeStr, + "legacy": strconv.FormatBool(key.GetLegacy()), + })) + } + t = t.WithRows(rows) + t = cli.WithListPaginationFooter(t, resp.GetPagination()) + common.HandleSuccess(cmd, "", t, resp) +} + +func policyListKeyMappings(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + id := c.Flags.GetOptionalID("id") + keyID := c.Flags.GetOptionalString("key-id") + kasIdentifier := c.Flags.GetOptionalString("kas") + + var keyIdentifier *kasregistry.KasKeyIdentifier + // Since keyID and kasIdentifier are required together, if one is provided, the other must be provided as well. + if id == "" && keyID != "" { + kasLookup, err := resolveKasIdentifier(kasIdentifier) + if err != nil { + cli.ExitWithError("Could not resolve KAS identifier", err) + } + keyIdentifier = &kasregistry.KasKeyIdentifier{ + Kid: keyID, + } + switch { + case kasLookup.ID != "": + keyIdentifier.Identifier = &kasregistry.KasKeyIdentifier_KasId{ + KasId: kasLookup.ID, + } + case kasLookup.URI != "": + keyIdentifier.Identifier = &kasregistry.KasKeyIdentifier_Uri{ + Uri: kasLookup.URI, + } + case kasLookup.Name != "": + keyIdentifier.Identifier = &kasregistry.KasKeyIdentifier_Name{ + Name: kasLookup.Name, + } + } + } + + resp, err := h.ListKeyMappings(c.Context(), limit, offset, id, keyIdentifier) + if err != nil { + cli.ExitWithError("Could not list key mappings", err) + } + + rows := getKeyMappingsTableRows(resp.GetKeyMappings()) + t := cli.NewTable( + table.NewFlexColumn("kas_uri", "KAS URI", cli.FlexColumnWidthOne), + table.NewFlexColumn("key_id", "Key ID", cli.FlexColumnWidthOne), + table.NewFlexColumn("namespace_mappings", "Namespaces", cli.FlexColumnWidthThree), + table.NewFlexColumn("attribute_mappings", "Attributes", cli.FlexColumnWidthThree), + table.NewFlexColumn("value_mappings", "Values", cli.FlexColumnWidthThree), + ).WithRows(rows) + t = cli.WithListPaginationFooter(t, resp.GetPagination()) + + common.HandleSuccess(cmd, "", t, resp) +} + +func getKeyMappingsTableRows(mappings []*kasregistry.KeyMapping) []table.Row { + rows := make([]table.Row, len(mappings)) + for i, m := range mappings { + rows[i] = table.NewRow(table.RowData{ + "kas_uri": m.GetKasUri(), + "key_id": m.GetKid(), + "namespace_mappings": formatMappedPolicyObject(m.GetNamespaceMappings()), + "attribute_mappings": formatMappedPolicyObject(m.GetAttributeMappings()), + "value_mappings": formatMappedPolicyObject(m.GetValueMappings()), + }) + } + return rows +} + +func formatMappedPolicyObject(m []*kasregistry.MappedPolicyObject) string { + if len(m) == 0 { + return "No mappings found" + } + fqns := make([]string, len(m)) + for i, obj := range m { + fqns[i] = obj.GetFqn() + } + return strings.Join(fqns, ", ") +} + +func policyRotateKasKey(cmd *cobra.Command, args []string) { + var wrappingKeyID string + + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + // Get parameters for the old key + oldKey := c.Flags.GetRequiredString("key") + + // Get parameters for creating the new key + newKeyID := c.Flags.GetRequiredString("key-id") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + // Use the helper function to get and validate key parameters + alg, mode, wrappingKeyID, err := prepareKeyParams(c) + if err != nil { + cli.ExitWithError("Invalid key parameters", err) + } + + // Use the helper function to prepare key contexts + publicKeyCtx, privateKeyCtx, providerConfigID, err := prepareKeyContexts(c, mode, alg, wrappingKeyID) + if err != nil { + cli.ExitWithError("Failed to prepare key contexts", err) + } + + // Create the new key request with the contexts created by the helper + newKey := &kasregistry.RotateKeyRequest_NewKey{ + KeyId: newKeyID, + Algorithm: alg, + KeyMode: mode, + PublicKeyCtx: publicKeyCtx, + PrivateKeyCtx: privateKeyCtx, + ProviderConfigId: providerConfigID, + Metadata: getMetadataMutable(metadataLabels), + } + + var identifier *kasregistry.KasKeyIdentifier + if utils.ClassifyString(oldKey) != utils.StringTypeUUID { + identifier, err = getKasKeyIdentifier(c) + if err != nil { + cli.ExitWithError("Invalid key identifier", err) + } + } + + // Call the rotate key function + rotateKeyResult, err := h.RotateKasKey( + c.Context(), + oldKey, + identifier, + newKey, + ) + if err != nil { + cli.ExitWithError("Failed to rotate key", err) + } + + rows := getTableRows(rotateKeyResult.KasKey) + if mdRows := getMetadataRows(rotateKeyResult.KasKey.GetKey().GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, rotateKeyResult.KasKey.GetKey().GetId(), t, rotateKeyResult) +} + +func resolveKasIdentifier(ident string) (handlers.KasIdentifier, error) { + // If the identifier is empty, it means no KAS filter is applied. + // Return an empty KasIdentifier and no error. + if ident == "" { + return handlers.KasIdentifier{}, nil + } + + // Use the ClassifyString helper to determine how to look up the KAS + kasLookup := handlers.KasIdentifier{} + kasInputType := utils.ClassifyString(ident) + + switch kasInputType { //nolint:exhaustive // default catches unknown + case utils.StringTypeUUID: + kasLookup.ID = ident + case utils.StringTypeURI: + kasLookup.URI = ident + case utils.StringTypeGeneric: + kasLookup.Name = ident + default: + return kasLookup, errors.New("invalid kas identifier") + } + + return kasLookup, nil +} + +// prepareKeyParams parses and validates the common key parameters used by both create and rotate operations. +// It returns the algorithm, mode, wrapping key ID, and any error that occurred. +func prepareKeyParams(c *cli.Cli) (policy.Algorithm, policy.KeyMode, string, error) { + // Parse algorithm + alg, err := cli.KeyAlgToEnum(c.Flags.GetRequiredString("algorithm")) + if err != nil { + return alg, 0, "", err + } + + // Parse mode + mode, err := modeToEnum(c.Flags.GetRequiredString("mode")) + if err != nil { + return alg, mode, "", err + } + + // Get wrapping key ID and validate based on mode + wrappingKeyID := c.Flags.GetOptionalString("wrapping-key-id") + if mode != policy.KeyMode_KEY_MODE_PUBLIC_KEY_ONLY && wrappingKeyID == "" { + formattedMode, _ := enumToMode(mode) + return alg, mode, "", fmt.Errorf("wrapping-key-id is required for mode %s", formattedMode) + } + + return alg, mode, wrappingKeyID, nil +} + +// prepareKeyContexts prepares the key contexts based on the specified mode and parameters. +// This function encapsulates the common logic between key creation and key rotation. +func prepareKeyContexts( + c *cli.Cli, + mode policy.KeyMode, + alg policy.Algorithm, + wrappingKeyID string, +) (*policy.PublicKeyCtx, *policy.PrivateKeyCtx, string, error) { + var publicKeyCtx *policy.PublicKeyCtx + var privateKeyCtx *policy.PrivateKeyCtx + var providerConfigID string + + switch mode { + case policy.KeyMode_KEY_MODE_CONFIG_ROOT_KEY: + // Local mode: generate keys locally and wrap with provided wrapping key + wrappingKey := c.Flags.GetRequiredString("wrapping-key") + wrappedKeyBytes, err := hex.DecodeString(wrappingKey) + if err != nil { + return nil, nil, "", errors.Join(errors.New("wrapping-key must be hex encoded"), err) + } + + privateKeyPem, publicKeyPem, err := generateKeys(alg) + if err != nil { + return nil, nil, "", errors.Join(errors.New("failed to generate keys"), err) + } + + privateKey, err := wrapKey(privateKeyPem, wrappedKeyBytes) + if err != nil { + return nil, nil, "", errors.Join(errors.New("failed to wrap key"), err) + } + + pubPemBase64 := base64.StdEncoding.EncodeToString([]byte(publicKeyPem)) + privPemBase64 := base64.StdEncoding.EncodeToString(privateKey) + publicKeyCtx = &policy.PublicKeyCtx{ + Pem: pubPemBase64, + } + privateKeyCtx = &policy.PrivateKeyCtx{ + KeyId: wrappingKeyID, + WrappedKey: privPemBase64, + } + case policy.KeyMode_KEY_MODE_PROVIDER_ROOT_KEY: + providerConfigID = c.Flags.GetRequiredString("provider-config-id") + publicPem := c.Flags.GetRequiredString("public-key-pem") + privatePem := c.Flags.GetRequiredString("private-key-pem") + decodedPub, err := base64.StdEncoding.DecodeString(publicPem) + if err != nil { + return nil, nil, "", errors.Join(errors.New("public key pem must be base64 encoded"), err) + } + if err := validatePublicKey(decodedPub, alg); err != nil { + return nil, nil, "", err + } + _, err = base64.StdEncoding.DecodeString(privatePem) + if err != nil { + return nil, nil, "", errors.Join(errors.New("private key pem must be base64 encoded"), err) + } + publicKeyCtx = &policy.PublicKeyCtx{ + Pem: publicPem, + } + privateKeyCtx = &policy.PrivateKeyCtx{ + KeyId: wrappingKeyID, + WrappedKey: privatePem, + } + case policy.KeyMode_KEY_MODE_REMOTE: + pem := c.Flags.GetRequiredString("public-key-pem") + providerConfigID = c.Flags.GetRequiredString("provider-config-id") + + decoded, err := base64.StdEncoding.DecodeString(pem) + if err != nil { + return nil, nil, "", errors.Join(errors.New("pem must be base64 encoded"), err) + } + if err := validatePublicKey(decoded, alg); err != nil { + return nil, nil, "", err + } + + publicKeyCtx = &policy.PublicKeyCtx{ + Pem: pem, + } + privateKeyCtx = &policy.PrivateKeyCtx{ + KeyId: wrappingKeyID, + } + case policy.KeyMode_KEY_MODE_PUBLIC_KEY_ONLY: + pem := c.Flags.GetRequiredString("public-key-pem") + decoded, err := base64.StdEncoding.DecodeString(pem) + if err != nil { + return nil, nil, "", errors.Join(errors.New("pem must be base64 encoded"), err) + } + if err := validatePublicKey(decoded, alg); err != nil { + return nil, nil, "", err + } + publicKeyCtx = &policy.PublicKeyCtx{ + Pem: pem, + } + case policy.KeyMode_KEY_MODE_UNSPECIFIED: + fallthrough + default: + return nil, nil, "", errors.New("invalid mode") + } + + return publicKeyCtx, privateKeyCtx, providerConfigID, nil +} + +// validatePublicKey validates the PEM public key against the expected algorithm. +func validatePublicKey(pemDecoded []byte, alg policy.Algorithm) error { + err := utils.ValidatePublicKeyPEM(pemDecoded, alg) + if err != nil { + return fmt.Errorf("invalid public key pem: %w", err) + } + return nil +} + +func policyUnsafeDeleteKasKey(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + id := c.Flags.GetRequiredID("id") + kid := c.Flags.GetRequiredString("key-id") + kasURI := c.Flags.GetRequiredString("kas-uri") + force := c.Flags.GetOptionalBool("force") + + cli.ConfirmAction(cli.ActionDelete, "key with kas uri: "+kasURI+", and key identifier: "+kid, "Id: "+id, force) + + key, err := h.UnsafeDeleteKasKey(ctx, id, kid, kasURI) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to delete key (%s)", id), err) + } + + rows := [][]string{ + {"Deleted", "true"}, + {"Id", key.GetKey().GetId()}, + {"KasURI", key.GetKasUri()}, + {"Key Identifier", key.GetKey().GetKeyId()}, + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, key) +} + +func getLegacyFlag(c *cli.Cli) (*bool, error) { + var ( + legacy *bool + err error + ) + + legacyStr := c.Flags.GetOptionalString("legacy") + if legacyStr == "" { + return legacy, nil + } + + legacy = new(bool) + *legacy, err = strconv.ParseBool(legacyStr) + if err != nil { + return nil, errors.New("invalid value for legacy flag. Must be true or false") + } + return legacy, nil +} + +func initKASKeysCommands() { + // Create Kas Key + createDoc := man.Docs.GetCommand("policy/kas-registry/key/create", + man.WithRun(policyCreateKasKey), + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("key-id").Name, + createDoc.GetDocFlag("key-id").Shorthand, + createDoc.GetDocFlag("key-id").Default, + createDoc.GetDocFlag("key-id").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("algorithm").Name, + createDoc.GetDocFlag("algorithm").Shorthand, + createDoc.GetDocFlag("algorithm").Default, + createDoc.GetDocFlag("algorithm").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("mode").Name, + createDoc.GetDocFlag("mode").Shorthand, + createDoc.GetDocFlag("mode").Default, + createDoc.GetDocFlag("mode").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("kas").Name, + createDoc.GetDocFlag("kas").Shorthand, + createDoc.GetDocFlag("kas").Default, + createDoc.GetDocFlag("kas").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("wrapping-key-id").Name, + createDoc.GetDocFlag("wrapping-key-id").Shorthand, + createDoc.GetDocFlag("wrapping-key-id").Default, + createDoc.GetDocFlag("wrapping-key-id").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("wrapping-key").Name, + createDoc.GetDocFlag("wrapping-key").Shorthand, + createDoc.GetDocFlag("wrapping-key").Default, + createDoc.GetDocFlag("wrapping-key").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("provider-config-id").Name, + createDoc.GetDocFlag("provider-config-id").Shorthand, + createDoc.GetDocFlag("provider-config-id").Default, + createDoc.GetDocFlag("provider-config-id").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("public-key-pem").Name, + createDoc.GetDocFlag("public-key-pem").Shorthand, + createDoc.GetDocFlag("public-key-pem").Default, + createDoc.GetDocFlag("public-key-pem").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("private-key-pem").Name, + createDoc.GetDocFlag("private-key-pem").Shorthand, + createDoc.GetDocFlag("private-key-pem").Default, + createDoc.GetDocFlag("private-key-pem").Description, + ) + injectLabelFlags(&createDoc.Command, false) + + // Get Kas Key + getDoc := man.Docs.GetCommand("policy/kas-registry/key/get", + man.WithRun(policyGetKasKey), + ) + getDoc.Flags().StringP( + getDoc.GetDocFlag("key").Name, + getDoc.GetDocFlag("key").Shorthand, + getDoc.GetDocFlag("key").Default, + getDoc.GetDocFlag("key").Description, + ) + getDoc.Flags().StringP( + getDoc.GetDocFlag("kas").Name, + getDoc.GetDocFlag("kas").Shorthand, + getDoc.GetDocFlag("kas").Default, + getDoc.GetDocFlag("kas").Description, + ) + // Update Kas Key + updateDoc := man.Docs.GetCommand("policy/kas-registry/key/update", + man.WithRun(policyUpdateKasKey), + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("id").Name, + updateDoc.GetDocFlag("id").Shorthand, + updateDoc.GetDocFlag("id").Default, + updateDoc.GetDocFlag("id").Description, + ) + injectLabelFlags(&updateDoc.Command, true) + + // List Kas Keys + listDoc := man.Docs.GetCommand("policy/kas-registry/key/list", + man.WithRun(policyListKasKeys), + ) + listDoc.Flags().StringP( + listDoc.GetDocFlag("algorithm").Name, + listDoc.GetDocFlag("algorithm").Shorthand, + listDoc.GetDocFlag("algorithm").Default, + listDoc.GetDocFlag("algorithm").Description, + ) + listDoc.Flags().StringP( + listDoc.GetDocFlag("kas").Name, + listDoc.GetDocFlag("kas").Shorthand, + listDoc.GetDocFlag("kas").Default, + listDoc.GetDocFlag("kas").Description, + ) + listDoc.Flags().StringP( + listDoc.GetDocFlag("legacy").Name, + listDoc.GetDocFlag("legacy").Shorthand, + listDoc.GetDocFlag("legacy").Default, + listDoc.GetDocFlag("legacy").Description, + ) + injectListPaginationFlags(listDoc) + + // Rotate Kas Key + rotateDoc := man.Docs.GetCommand("policy/kas-registry/key/rotate", + man.WithRun(policyRotateKasKey), + ) + rotateDoc.Flags().StringP( + rotateDoc.GetDocFlag("key").Name, + rotateDoc.GetDocFlag("key").Shorthand, + rotateDoc.GetDocFlag("key").Default, + rotateDoc.GetDocFlag("key").Description, + ) + rotateDoc.Flags().StringP( + rotateDoc.GetDocFlag("kas").Name, + rotateDoc.GetDocFlag("kas").Shorthand, + rotateDoc.GetDocFlag("kas").Default, + rotateDoc.GetDocFlag("kas").Description, + ) + rotateDoc.Flags().StringP( + rotateDoc.GetDocFlag("key-id").Name, + rotateDoc.GetDocFlag("key-id").Shorthand, + rotateDoc.GetDocFlag("key-id").Default, + rotateDoc.GetDocFlag("key-id").Description, + ) + rotateDoc.Flags().StringP( + rotateDoc.GetDocFlag("algorithm").Name, + rotateDoc.GetDocFlag("algorithm").Shorthand, + rotateDoc.GetDocFlag("algorithm").Default, + rotateDoc.GetDocFlag("algorithm").Description, + ) + rotateDoc.Flags().StringP( + rotateDoc.GetDocFlag("mode").Name, + rotateDoc.GetDocFlag("mode").Shorthand, + rotateDoc.GetDocFlag("mode").Default, + rotateDoc.GetDocFlag("mode").Description, + ) + rotateDoc.Flags().StringP( + rotateDoc.GetDocFlag("wrapping-key-id").Name, + rotateDoc.GetDocFlag("wrapping-key-id").Shorthand, + rotateDoc.GetDocFlag("wrapping-key-id").Default, + rotateDoc.GetDocFlag("wrapping-key-id").Description, + ) + rotateDoc.Flags().StringP( + rotateDoc.GetDocFlag("wrapping-key").Name, + rotateDoc.GetDocFlag("wrapping-key").Shorthand, + rotateDoc.GetDocFlag("wrapping-key").Default, + rotateDoc.GetDocFlag("wrapping-key").Description, + ) + rotateDoc.Flags().StringP( + rotateDoc.GetDocFlag("provider-config-id").Name, + rotateDoc.GetDocFlag("provider-config-id").Shorthand, + rotateDoc.GetDocFlag("provider-config-id").Default, + rotateDoc.GetDocFlag("provider-config-id").Description, + ) + rotateDoc.Flags().StringP( + rotateDoc.GetDocFlag("public-key-pem").Name, + rotateDoc.GetDocFlag("public-key-pem").Shorthand, + rotateDoc.GetDocFlag("public-key-pem").Default, + rotateDoc.GetDocFlag("public-key-pem").Description, + ) + rotateDoc.Flags().StringP( + rotateDoc.GetDocFlag("private-key-pem").Name, + rotateDoc.GetDocFlag("private-key-pem").Shorthand, + rotateDoc.GetDocFlag("private-key-pem").Default, + rotateDoc.GetDocFlag("private-key-pem").Description, + ) + injectLabelFlags(&rotateDoc.Command, true) + + // Import Kas Key + importDoc := man.Docs.GetCommand("policy/kas-registry/key/import", + man.WithRun(policyImportKasKey), + ) + importDoc.Flags().StringP( + importDoc.GetDocFlag("key-id").Name, + importDoc.GetDocFlag("key-id").Shorthand, + importDoc.GetDocFlag("key-id").Default, + importDoc.GetDocFlag("key-id").Description, + ) + importDoc.Flags().StringP( + importDoc.GetDocFlag("algorithm").Name, + importDoc.GetDocFlag("algorithm").Shorthand, + importDoc.GetDocFlag("algorithm").Default, + importDoc.GetDocFlag("algorithm").Description, + ) + importDoc.Flags().StringP( + importDoc.GetDocFlag("kas").Name, + importDoc.GetDocFlag("kas").Shorthand, + importDoc.GetDocFlag("kas").Default, + importDoc.GetDocFlag("kas").Description, + ) + importDoc.Flags().StringP( + importDoc.GetDocFlag("wrapping-key-id").Name, + importDoc.GetDocFlag("wrapping-key-id").Shorthand, + importDoc.GetDocFlag("wrapping-key-id").Default, + importDoc.GetDocFlag("wrapping-key-id").Description, + ) + importDoc.Flags().StringP( + importDoc.GetDocFlag("wrapping-key").Name, + importDoc.GetDocFlag("wrapping-key").Shorthand, + importDoc.GetDocFlag("wrapping-key").Default, + importDoc.GetDocFlag("wrapping-key").Description, + ) + importDoc.Flags().StringP( + importDoc.GetDocFlag("public-key-pem").Name, + importDoc.GetDocFlag("public-key-pem").Shorthand, + importDoc.GetDocFlag("public-key-pem").Default, + importDoc.GetDocFlag("public-key-pem").Description, + ) + importDoc.Flags().StringP( + importDoc.GetDocFlag("private-key-pem").Name, + importDoc.GetDocFlag("private-key-pem").Shorthand, + importDoc.GetDocFlag("private-key-pem").Default, + importDoc.GetDocFlag("private-key-pem").Description, + ) + importDoc.Flags().StringP( + importDoc.GetDocFlag("legacy").Name, + importDoc.GetDocFlag("legacy").Shorthand, + importDoc.GetDocFlag("legacy").Default, + importDoc.GetDocFlag("legacy").Description, + ) + injectLabelFlags(&importDoc.Command, false) + + mappingsDoc := man.Docs.GetCommand("policy/kas-registry/key/list-mappings", + man.WithRun(policyListKeyMappings), + ) + mappingsDoc.Flags().StringP( + mappingsDoc.GetDocFlag("id").Name, + mappingsDoc.GetDocFlag("id").Shorthand, + mappingsDoc.GetDocFlag("id").Default, + mappingsDoc.GetDocFlag("id").Description, + ) + mappingsDoc.Flags().StringP( + mappingsDoc.GetDocFlag("key-id").Name, + mappingsDoc.GetDocFlag("key-id").Shorthand, + mappingsDoc.GetDocFlag("key-id").Default, + mappingsDoc.GetDocFlag("key-id").Description, + ) + mappingsDoc.Flags().StringP( + mappingsDoc.GetDocFlag("kas").Name, + mappingsDoc.GetDocFlag("kas").Shorthand, + mappingsDoc.GetDocFlag("kas").Default, + mappingsDoc.GetDocFlag("kas").Description, + ) + mappingsDoc.MarkFlagsMutuallyExclusive("key-id", "id") + mappingsDoc.MarkFlagsMutuallyExclusive("kas", "id") + mappingsDoc.MarkFlagsRequiredTogether("key-id", "kas") + injectListPaginationFlags(mappingsDoc) + + // Unsafe Delete Kas Key + unsafeCmd := man.Docs.GetCommand("policy/kas-registry/key/unsafe") + unsafeCmd.PersistentFlags().Bool( + unsafeCmd.GetDocFlag("force").Name, + false, + unsafeCmd.GetDocFlag("force").Description, + ) + + unsafeDeleteDoc := man.Docs.GetCommand("policy/kas-registry/key/unsafe/delete", + man.WithRun(policyUnsafeDeleteKasKey), + ) + unsafeDeleteDoc.Flags().StringP( + unsafeDeleteDoc.GetDocFlag("id").Name, + unsafeDeleteDoc.GetDocFlag("id").Shorthand, + unsafeDeleteDoc.GetDocFlag("id").Default, + unsafeDeleteDoc.GetDocFlag("id").Description, + ) + unsafeDeleteDoc.Flags().StringP( + unsafeDeleteDoc.GetDocFlag("key-id").Name, + unsafeDeleteDoc.GetDocFlag("key-id").Shorthand, + unsafeDeleteDoc.GetDocFlag("key-id").Default, + unsafeDeleteDoc.GetDocFlag("key-id").Description, + ) + unsafeDeleteDoc.Flags().StringP( + unsafeDeleteDoc.GetDocFlag("kas-uri").Name, + unsafeDeleteDoc.GetDocFlag("kas-uri").Shorthand, + unsafeDeleteDoc.GetDocFlag("kas-uri").Default, + unsafeDeleteDoc.GetDocFlag("kas-uri").Description, + ) + + unsafeCmd.AddSubcommands(unsafeDeleteDoc) + policyKasRegistryKeysCmd.AddSubcommands(createDoc, getDoc, updateDoc, listDoc, rotateDoc, importDoc, mappingsDoc, unsafeCmd) + KasRegistryCmd.AddCommand(&policyKasRegistryKeysCmd.Command) +} diff --git a/otdfctl/cmd/policy/kasRegistry.go b/otdfctl/cmd/policy/kasRegistry.go new file mode 100644 index 0000000000..006c9fecab --- /dev/null +++ b/otdfctl/cmd/policy/kasRegistry.go @@ -0,0 +1,311 @@ +package policy + +import ( + "fmt" + + "github.com/evertras/bubble-table/table" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/handlers" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/spf13/cobra" +) + +var KasRegistryCmd = man.Docs.GetCommand("policy/kas-registry") + +func getKeyAccessRegistry(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.FlagHelper.GetRequiredID("id") + + kas, err := h.GetKasRegistryEntry(cmd.Context(), handlers.KasIdentifier{ + ID: id, + }) + if err != nil { + errMsg := fmt.Sprintf("Failed to get Registered KAS entry (%s)", id) + cli.ExitWithError(errMsg, err) + } + + // TODO: Remove in next release + key := &policy.PublicKey{} + key.PublicKey = &policy.PublicKey_Cached{Cached: kas.GetPublicKey().GetCached()} + if kas.GetPublicKey().GetRemote() != "" { + key.PublicKey = &policy.PublicKey_Remote{Remote: kas.GetPublicKey().GetRemote()} + } + + rows := [][]string{ + {"Id", kas.GetId()}, + {"URI", kas.GetUri()}, + {"PublicKey", kas.GetPublicKey().String()}, + } + name := kas.GetName() + if name != "" { + rows = append(rows, []string{"Name", name}) + } + + if mdRows := getMetadataRows(kas.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + + common.HandleSuccess(cmd, kas.GetId(), t, kas) +} + +func listKeyAccessRegistries(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + + resp, err := h.ListKasRegistryEntries(cmd.Context(), limit, offset) + if err != nil { + cli.ExitWithError("Failed to list Registered KAS entries", err) + } + + t := cli.NewTable( + cli.NewUUIDColumn(), + table.NewFlexColumn("uri", "URI", cli.FlexColumnWidthFour), + table.NewFlexColumn("name", "Name", cli.FlexColumnWidthThree), + table.NewFlexColumn("pk", "PublicKey", cli.FlexColumnWidthFour), + ) + rows := []table.Row{} + for _, kas := range resp.GetKeyAccessServers() { + // TODO: Remove in next release + key := policy.PublicKey{} + key.PublicKey = &policy.PublicKey_Cached{Cached: kas.GetPublicKey().GetCached()} + if kas.GetPublicKey().GetRemote() != "" { + key.PublicKey = &policy.PublicKey_Remote{Remote: kas.GetPublicKey().GetRemote()} + } + rows = append(rows, table.NewRow(table.RowData{ + "id": kas.GetId(), + "uri": kas.GetUri(), + "name": kas.GetName(), + "pk": kas.GetPublicKey().String(), + })) + } + t = t.WithRows(rows) + t = cli.WithListPaginationFooter(t, resp.GetPagination()) + common.HandleSuccess(cmd, "", t, resp) +} + +func createKeyAccessRegistry(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + uri := c.Flags.GetRequiredString("uri") + cachedJSON := c.Flags.GetOptionalString("public-keys") // Deprecated + remote := c.Flags.GetOptionalString("public-key-remote") // Deprecated + name := c.Flags.GetOptionalString("name") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + if cachedJSON != "" || remote != "" { + message := "\nDEPRECATION WARNING: --public-keys and --public-key-remote are deprecated and will be removed in an upcoming release.\n" + + "Please use the 'policy kas-registry key' command instead.\n" + cmd.Println(cli.WarningMessage(message)) + } + + created, err := h.CreateKasRegistryEntry( + cmd.Context(), + uri, + name, + getMetadataMutable(metadataLabels), + ) + if err != nil { + cli.ExitWithError("Failed to create Registered KAS entry", err) + } + + rows := [][]string{ + {"Id", created.GetId()}, + {"URI", created.GetUri()}, + } + if name != "" { + rows = append(rows, []string{"Name", name}) + } + if mdRows := getMetadataRows(created.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + + common.HandleSuccess(cmd, created.GetId(), t, created) +} + +func updateKeyAccessRegistry(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + uri := c.Flags.GetOptionalString("uri") + name := c.Flags.GetOptionalString("name") + cachedJSON := c.Flags.GetOptionalString("public-keys") // Deprecated + remote := c.Flags.GetOptionalString("public-key-remote") // Deprecated + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + if cachedJSON != "" || remote != "" { + message := "\nDEPRECATION WARNING: --public-keys and --public-key-remote are deprecated and will be removed in an upcoming release.\n" + + "Please use the 'policy kas-registry key' command instead.\n" + cmd.Println(cli.WarningMessage(message)) + } + + updated, err := h.UpdateKasRegistryEntry( + cmd.Context(), + id, + uri, + name, + getMetadataMutable(metadataLabels), + getMetadataUpdateBehavior(), + ) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to update Registered KAS entry (%s)", id), err) + } + rows := [][]string{ + {"Id", id}, + {"URI", updated.GetUri()}, + } + if updated.GetName() != "" { + rows = append(rows, []string{"Name", updated.GetName()}) + } + + if mdRows := getMetadataRows(updated.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, updated) +} + +func deleteKeyAccessRegistry(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + id := c.Flags.GetRequiredID("id") + force := c.Flags.GetOptionalBool("force") + + kas, err := h.GetKasRegistryEntry(ctx, handlers.KasIdentifier{ + ID: id, + }) + if err != nil { + errMsg := fmt.Sprintf("Failed to get Registered KAS entry (%s)", id) + cli.ExitWithError(errMsg, err) + } + + cli.ConfirmAction(cli.ActionDelete, "Registered KAS", id, force) + + if _, err := h.DeleteKasRegistryEntry(ctx, id); err != nil { + errMsg := fmt.Sprintf("Failed to delete Registered KAS entry (%s)", id) + cli.ExitWithError(errMsg, err) + } + + t := cli.NewTabular( + []string{"Id", kas.GetId()}, + []string{"URI", kas.GetUri()}, + ) + + common.HandleSuccess(cmd, kas.GetId(), t, kas) +} + +func initKASRegistryCommands() { + getDoc := man.Docs.GetCommand("policy/kas-registry/get", + man.WithRun(getKeyAccessRegistry), + ) + getDoc.Flags().StringP( + getDoc.GetDocFlag("id").Name, + getDoc.GetDocFlag("id").Shorthand, + getDoc.GetDocFlag("id").Default, + getDoc.GetDocFlag("id").Description, + ) + + listDoc := man.Docs.GetCommand("policy/kas-registry/list", + man.WithRun(listKeyAccessRegistries), + ) + injectListPaginationFlags(listDoc) + + createDoc := man.Docs.GetCommand("policy/kas-registry/create", + man.WithRun(createKeyAccessRegistry), + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("uri").Name, + createDoc.GetDocFlag("uri").Shorthand, + createDoc.GetDocFlag("uri").Default, + createDoc.GetDocFlag("uri").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("public-keys").Name, + createDoc.GetDocFlag("public-keys").Shorthand, + createDoc.GetDocFlag("public-keys").Default, + createDoc.GetDocFlag("public-keys").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("public-key-remote").Name, + createDoc.GetDocFlag("public-key-remote").Shorthand, + createDoc.GetDocFlag("public-key-remote").Default, + createDoc.GetDocFlag("public-key-remote").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("name").Name, + createDoc.GetDocFlag("name").Shorthand, + createDoc.GetDocFlag("name").Default, + createDoc.GetDocFlag("name").Description, + ) + injectLabelFlags(&createDoc.Command, false) + + updateDoc := man.Docs.GetCommand("policy/kas-registry/update", + man.WithRun(updateKeyAccessRegistry), + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("id").Name, + updateDoc.GetDocFlag("id").Shorthand, + updateDoc.GetDocFlag("id").Default, + updateDoc.GetDocFlag("id").Description, + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("uri").Name, + updateDoc.GetDocFlag("uri").Shorthand, + updateDoc.GetDocFlag("uri").Default, + updateDoc.GetDocFlag("uri").Description, + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("public-keys").Name, + updateDoc.GetDocFlag("public-keys").Shorthand, + updateDoc.GetDocFlag("public-keys").Default, + updateDoc.GetDocFlag("public-keys").Description, + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("public-key-remote").Name, + updateDoc.GetDocFlag("public-key-remote").Shorthand, + updateDoc.GetDocFlag("public-key-remote").Default, + updateDoc.GetDocFlag("public-key-remote").Description, + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("name").Name, + updateDoc.GetDocFlag("name").Shorthand, + updateDoc.GetDocFlag("name").Default, + updateDoc.GetDocFlag("name").Description, + ) + injectLabelFlags(&updateDoc.Command, true) + + deleteDoc := man.Docs.GetCommand("policy/kas-registry/delete", + man.WithRun(deleteKeyAccessRegistry), + ) + deleteDoc.Flags().StringP( + deleteDoc.GetDocFlag("id").Name, + deleteDoc.GetDocFlag("id").Shorthand, + deleteDoc.GetDocFlag("id").Default, + deleteDoc.GetDocFlag("id").Description, + ) + deleteDoc.Flags().Bool( + deleteDoc.GetDocFlag("force").Name, + false, + deleteDoc.GetDocFlag("force").Description, + ) + + KasRegistryCmd.AddSubcommands(createDoc, getDoc, listDoc, updateDoc, deleteDoc) + Cmd.AddCommand(&KasRegistryCmd.Command) +} diff --git a/otdfctl/cmd/policy/keyManagement.go b/otdfctl/cmd/policy/keyManagement.go new file mode 100644 index 0000000000..69216b205b --- /dev/null +++ b/otdfctl/cmd/policy/keyManagement.go @@ -0,0 +1,13 @@ +package policy + +import ( + "github.com/opentdf/platform/otdfctl/pkg/man" +) + +// KeyManagementCmd is the command for managing keys +var KeyManagementCmd = man.Docs.GetCommand("policy/key-management") + +// initKeyManagementCommands sets up the key-management command. +func initKeyManagementCommands() { + Cmd.AddCommand(&KeyManagementCmd.Command) +} diff --git a/otdfctl/cmd/policy/keyManagementProvider.go b/otdfctl/cmd/policy/keyManagementProvider.go new file mode 100644 index 0000000000..55b31f8d94 --- /dev/null +++ b/otdfctl/cmd/policy/keyManagementProvider.go @@ -0,0 +1,281 @@ +package policy + +import ( + "github.com/evertras/bubble-table/table" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/spf13/cobra" +) + +func createProviderConfig(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + name := c.Flags.GetRequiredString("name") + manager := c.Flags.GetRequiredString("manager") + config := c.Flags.GetRequiredString("config") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + // Do not need to get provider config after, since this endpoint returns the created config. + pc, err := h.CreateProviderConfig(c.Context(), name, manager, []byte(config), getMetadataMutable(metadataLabels)) + if err != nil { + cli.ExitWithError("Failed to create provider config", err) + } + + rows := [][]string{ + {"ID", pc.GetId()}, + {"Name", pc.GetName()}, + {"Config", string(pc.GetConfigJson())}, + {"Manager", pc.GetManager()}, + } + + if mdRows := getMetadataRows(pc.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + + common.HandleSuccess(cmd, pc.GetId(), t, pc) +} + +func getProviderConfig(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetOptionalID("id") + name := c.Flags.GetOptionalString("name") + + pc, err := h.GetProviderConfig(c.Context(), id, name) + if err != nil { + cli.ExitWithError("Failed to get provider config", err) + } + + rows := [][]string{ + {"ID", pc.GetId()}, + {"Name", pc.GetName()}, + {"Config", string(pc.GetConfigJson())}, + {"Manager", pc.GetManager()}, + } + + if mdRows := getMetadataRows(pc.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + + common.HandleSuccess(cmd, pc.GetId(), t, pc) +} + +func updateProviderConfig(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + name := c.Flags.GetOptionalString("name") + manager := c.Flags.GetOptionalString("manager") + config := c.Flags.GetOptionalString("config") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + if name == "" && manager == "" && config == "" && len(metadataLabels) == 0 { + cli.ExitWithError("At least one field (name, manager, config, or metadata labels) must be updated", nil) + } + + pc, err := h.UpdateProviderConfig(c.Context(), id, name, manager, []byte(config), getMetadataMutable(metadataLabels), getMetadataUpdateBehavior()) + if err != nil { + cli.ExitWithError("Failed to update provider config", err) + } + + rows := [][]string{ + {"ID", pc.GetId()}, + {"Name", pc.GetName()}, + {"Config", string(pc.GetConfigJson())}, + {"Manager", pc.GetManager()}, + } + + if mdRows := getMetadataRows(pc.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + + common.HandleSuccess(cmd, pc.GetId(), t, pc) +} + +func listProviderConfig(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + + // Get all provider configs + resp, err := h.ListProviderConfigs(c.Context(), limit, offset) + if err != nil { + cli.ExitWithError("Failed to list provider configs", err) + } + + t := cli.NewTable( + // columns should be id, name, config, labels, created_at, updated_at + table.NewFlexColumn("id", "Provider Config ID", cli.FlexColumnWidthThree), + table.NewFlexColumn("name", "Provider Config Name", cli.FlexColumnWidthTwo), + table.NewFlexColumn("manager", "Manager", cli.FlexColumnWidthTwo), + table.NewFlexColumn("config", "Provider Config", cli.FlexColumnWidthOne), + table.NewFlexColumn("labels", "Labels", cli.FlexColumnWidthOne), + table.NewFlexColumn("created_at", "Created At", cli.FlexColumnWidthOne), + table.NewFlexColumn("updated_at", "Updated At", cli.FlexColumnWidthOne), + ) + rows := []table.Row{} + for _, pc := range resp.GetProviderConfigs() { + metadata := cli.ConstructMetadata(pc.GetMetadata()) + rows = append(rows, table.NewRow(table.RowData{ + "id": pc.GetId(), + "name": pc.GetName(), + "config": string(pc.GetConfigJson()), + "labels": metadata["Labels"], + "created_at": metadata["Created At"], + "updated_at": metadata["Updated At"], + "manager": pc.GetManager(), + })) + } + t = t.WithRows(rows) + t = cli.WithListPaginationFooter(t, resp.GetPagination()) + common.HandleSuccess(cmd, "", t, resp) +} + +func deleteProviderConfig(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + force := c.Flags.GetOptionalBool("force") + + // Get provider config. + pc, err := h.GetProviderConfig(c.Context(), id, "") + if err != nil { + cli.ExitWithError("Failed to get provider config", err) + } + + cli.ConfirmAction(cli.ActionDelete, "key provider config with id: "+id, "Provider Name: "+pc.GetName(), force) + + err = h.DeleteProviderConfig(c.Context(), id) + if err != nil { + cli.ExitWithError("Failed to delete provider config", err) + } + + rows := [][]string{ + {"Deleted", "true"}, + {"Id", id}, + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, nil) +} + +func initKeyManagementProviderCommands() { + // Create Provider Config + createDoc := man.Docs.GetCommand("policy/key-management/provider/create", + man.WithRun(createProviderConfig), + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("name").Name, + createDoc.GetDocFlag("name").Shorthand, + createDoc.GetDocFlag("name").Default, + createDoc.GetDocFlag("name").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("manager").Name, + createDoc.GetDocFlag("manager").Shorthand, + createDoc.GetDocFlag("manager").Default, + createDoc.GetDocFlag("manager").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("config").Name, + createDoc.GetDocFlag("config").Shorthand, + createDoc.GetDocFlag("config").Default, + createDoc.GetDocFlag("config").Description, + ) + injectLabelFlags(&createDoc.Command, false) + + // Get Provider Config + getDoc := man.Docs.GetCommand("policy/key-management/provider/get", + man.WithRun(getProviderConfig), + ) + getDoc.Flags().StringP( + getDoc.GetDocFlag("id").Name, + getDoc.GetDocFlag("id").Shorthand, + getDoc.GetDocFlag("id").Default, + getDoc.GetDocFlag("id").Description, + ) + getDoc.Flags().StringP( + getDoc.GetDocFlag("name").Name, + getDoc.GetDocFlag("name").Shorthand, + getDoc.GetDocFlag("name").Default, + getDoc.GetDocFlag("name").Description, + ) + getDoc.MarkFlagsOneRequired("id", "name") + getDoc.MarkFlagsMutuallyExclusive("id", "name") + + // Update Provider Config + updateDoc := man.Docs.GetCommand("policy/key-management/provider/update", + man.WithRun(updateProviderConfig), + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("id").Name, + updateDoc.GetDocFlag("id").Shorthand, + updateDoc.GetDocFlag("id").Default, + updateDoc.GetDocFlag("id").Description, + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("name").Name, + updateDoc.GetDocFlag("name").Shorthand, + updateDoc.GetDocFlag("name").Default, + updateDoc.GetDocFlag("name").Description, + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("manager").Name, + updateDoc.GetDocFlag("manager").Shorthand, + updateDoc.GetDocFlag("manager").Default, + updateDoc.GetDocFlag("manager").Description, + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("config").Name, + updateDoc.GetDocFlag("config").Shorthand, + updateDoc.GetDocFlag("config").Default, + updateDoc.GetDocFlag("config").Description, + ) + injectLabelFlags(&updateDoc.Command, true) + + // List Provider Configs + listDoc := man.Docs.GetCommand("policy/key-management/provider/list", + man.WithRun(listProviderConfig), + ) + injectListPaginationFlags(listDoc) + + // Add Delete Provider Config + deleteDoc := man.Docs.GetCommand("policy/key-management/provider/delete", + man.WithRun(deleteProviderConfig), + ) + deleteDoc.Flags().StringP( + deleteDoc.GetDocFlag("id").Name, + deleteDoc.GetDocFlag("id").Shorthand, + deleteDoc.GetDocFlag("id").Default, + deleteDoc.GetDocFlag("id").Description, + ) + deleteDoc.Flags().BoolP( + deleteDoc.GetDocFlag("force").Name, + deleteDoc.GetDocFlag("force").Shorthand, + false, + deleteDoc.GetDocFlag("force").Description, + ) + + doc := man.Docs.GetCommand("policy/key-management/provider", + man.WithSubcommands(createDoc, getDoc, updateDoc, listDoc, deleteDoc)) + + KeyManagementCmd.AddCommand(&doc.Command) +} diff --git a/otdfctl/cmd/policy/namespaces.go b/otdfctl/cmd/policy/namespaces.go new file mode 100644 index 0000000000..2605845c02 --- /dev/null +++ b/otdfctl/cmd/policy/namespaces.go @@ -0,0 +1,491 @@ +package policy + +import ( + "fmt" + "strconv" + + "github.com/evertras/bubble-table/table" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/spf13/cobra" +) + +var forceUnsafe bool + +func getAttributeNamespace(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + + ns, err := h.GetNamespace(cmd.Context(), id) + if err != nil { + errMsg := fmt.Sprintf("Failed to get namespace (%s)", id) + cli.ExitWithError(errMsg, err) + } + + rows := [][]string{ + {"Id", ns.GetId()}, + {"Name", ns.GetName()}, + } + if mdRows := getMetadataRows(ns.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, ns.GetId(), t, ns) +} + +func listAttributeNamespaces(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + state := cli.GetState(cmd) + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + + resp, err := h.ListNamespaces(cmd.Context(), state, limit, offset) + if err != nil { + cli.ExitWithError("Failed to list namespaces", err) + } + t := cli.NewTable( + cli.NewUUIDColumn(), + table.NewFlexColumn("name", "Name", cli.FlexColumnWidthFour), + table.NewFlexColumn("active", "Active", cli.FlexColumnWidthThree), + table.NewFlexColumn("labels", "Labels", cli.FlexColumnWidthOne), + table.NewFlexColumn("created_at", "Created At", cli.FlexColumnWidthOne), + table.NewFlexColumn("updated_at", "Updated At", cli.FlexColumnWidthOne), + ) + rows := []table.Row{} + for _, ns := range resp.GetNamespaces() { + metadata := cli.ConstructMetadata(ns.GetMetadata()) + rows = append(rows, + table.NewRow(table.RowData{ + "id": ns.GetId(), + "name": ns.GetName(), + "active": strconv.FormatBool(ns.GetActive().GetValue()), + "labels": metadata["Labels"], + "created_at": metadata["Created At"], + "updated_at": metadata["Updated At"], + }), + ) + } + t = t.WithRows(rows) + t = cli.WithListPaginationFooter(t, resp.GetPagination()) + common.HandleSuccess(cmd, "", t, resp) +} + +func createAttributeNamespace(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + name := c.Flags.GetRequiredString("name") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + created, err := h.CreateNamespace(cmd.Context(), name, getMetadataMutable(metadataLabels)) + if err != nil { + cli.ExitWithError("Failed to create namespace", err) + } + rows := [][]string{ + {"Name", name}, + {"Id", created.GetId()}, + } + if mdRows := getMetadataRows(created.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, created.GetId(), t, created) +} + +func deactivateAttributeNamespace(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + force := c.Flags.GetOptionalBool("force") + id := c.Flags.GetRequiredID("id") + + ns, err := h.GetNamespace(ctx, id) + if err != nil { + errMsg := fmt.Sprintf("Failed to find namespace (%s)", id) + cli.ExitWithError(errMsg, err) + } + + cli.ConfirmAction(cli.ActionDeactivate, "namespace", ns.GetName(), force) + + d, err := h.DeactivateNamespace(ctx, id) + if err != nil { + errMsg := fmt.Sprintf("Failed to deactivate namespace (%s)", id) + cli.ExitWithError(errMsg, err) + } + rows := [][]string{ + {"Id", ns.GetId()}, + {"Name", ns.GetName()}, + } + if mdRows := getMetadataRows(d.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, ns.GetId(), t, d) +} + +func updateAttributeNamespace(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + ns, err := h.UpdateNamespace( + cmd.Context(), + id, + getMetadataMutable(metadataLabels), + getMetadataUpdateBehavior(), + ) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to update namespace (%s)", id), err) + } + rows := [][]string{ + {"Id", ns.GetId()}, + {"Name", ns.GetName()}, + } + if mdRows := getMetadataRows(ns.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, ns) +} + +func unsafeDeleteAttributeNamespace(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + id := c.Flags.GetRequiredID("id") + + ns, err := h.GetNamespace(ctx, id) + if err != nil { + errMsg := fmt.Sprintf("Failed to find namespace (%s)", id) + cli.ExitWithError(errMsg, err) + } + + if !forceUnsafe { + cli.ConfirmTextInput(cli.ActionDelete, "namespace", cli.InputNameFQN, ns.GetFqn()) + } + + if err := h.UnsafeDeleteNamespace(ctx, id, ns.GetFqn()); err != nil { + errMsg := fmt.Sprintf("Failed to delete namespace (%s)", id) + cli.ExitWithError(errMsg, err) + } + + rows := [][]string{ + {"Id", ns.GetId()}, + {"Name", ns.GetName()}, + } + if mdRows := getMetadataRows(ns.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, ns.GetId(), t, ns) +} + +func unsafeReactivateAttributeNamespace(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + id := c.Flags.GetRequiredID("id") + + ns, err := h.GetNamespace(ctx, id) + if err != nil { + errMsg := fmt.Sprintf("Failed to find namespace (%s)", id) + cli.ExitWithError(errMsg, err) + } + + if !forceUnsafe { + cli.ConfirmTextInput(cli.ActionReactivate, "namespace", cli.InputNameFQN, ns.GetFqn()) + } + + ns, err = h.UnsafeReactivateNamespace(ctx, id) + if err != nil { + errMsg := fmt.Sprintf("Failed to reactivate namespace (%s)", id) + cli.ExitWithError(errMsg, err) + } + + rows := [][]string{ + {"Id", ns.GetId()}, + {"Name", ns.GetName()}, + } + if mdRows := getMetadataRows(ns.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, ns.GetId(), t, ns) +} + +func unsafeUpdateAttributeNamespace(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + id := c.Flags.GetRequiredID("id") + name := c.Flags.GetRequiredString("name") + + ns, err := h.GetNamespace(ctx, id) + if err != nil { + errMsg := fmt.Sprintf("Failed to find namespace (%s)", id) + cli.ExitWithError(errMsg, err) + } + + if !forceUnsafe { + cli.ConfirmTextInput(cli.ActionUpdateUnsafe, "namespace", cli.InputNameFQNUpdated, ns.GetFqn()) + } + + ns, err = h.UnsafeUpdateNamespace(ctx, id, name) + if err != nil { + errMsg := fmt.Sprintf("Failed to reactivate namespace (%s)", id) + cli.ExitWithError(errMsg, err) + } + + rows := [][]string{ + {"Id", ns.GetId()}, + {"Name", ns.GetName()}, + } + if mdRows := getMetadataRows(ns.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, ns.GetId(), t, ns) +} + +func policyAssignKeyToNamespace(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + namespace := c.Flags.GetRequiredString("namespace") + keyID := c.Flags.GetRequiredID("key-id") + + // Get the attribute namespace to show meaningful information in case of error + attrKey, err := h.AssignKeyToAttributeNamespace(c.Context(), namespace, keyID) + if err != nil { + errMsg := fmt.Sprintf("Failed to assign key: (%s) to attribute namespace: (%s)", keyID, namespace) + cli.ExitWithError(errMsg, err) + } + + // Prepare and display the result + rows := [][]string{ + {"Namespace ID", attrKey.GetNamespaceId()}, + {"Key ID", attrKey.GetKeyId()}, + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, namespace, t, attrKey) +} + +func policyRemoveKeyFromNamespace(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + namespace := c.Flags.GetRequiredString("namespace") + keyID := c.Flags.GetRequiredID("key-id") + + err := h.RemoveKeyFromAttributeNamespace(c.Context(), namespace, keyID) + if err != nil { + errMsg := fmt.Sprintf("Failed to remove key (%s) from attribute namespace (%s)", keyID, namespace) + cli.ExitWithError(errMsg, err) + } + + // Prepare and display the result + rows := [][]string{ + {"Removed", "true"}, + {"Namespace", namespace}, + {"Key ID", keyID}, + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, namespace, t, nil) +} + +// newCommandFromDoc creates an independent cobra.Command with metadata copied from a Doc. +// This allows registering the same logical command under multiple parents. +func newCommandFromDoc(doc *man.Doc, run func(*cobra.Command, []string)) *cobra.Command { + cmd := &cobra.Command{ + Use: doc.Use, + Short: doc.Short, + Long: doc.Long, + Args: doc.Args, + Aliases: doc.Aliases, + Hidden: doc.Hidden, + Run: run, + } + return cmd +} + +// buildNamespacesCommandTree creates a full namespaces command tree with all subcommands and flags. +// Each call returns an independent *cobra.Command so it can be parented under multiple commands. +func buildNamespacesCommandTree() *cobra.Command { + nsDoc := man.Docs.GetDoc("policy/namespaces") + nsCmd := newCommandFromDoc(nsDoc, nil) + + getDoc := man.Docs.GetDoc("policy/namespaces/get") + getCmd := newCommandFromDoc(getDoc, getAttributeNamespace) + getCmd.Flags().StringP( + getDoc.GetDocFlag("id").Name, + getDoc.GetDocFlag("id").Shorthand, + getDoc.GetDocFlag("id").Default, + getDoc.GetDocFlag("id").Description, + ) + + listDoc := man.Docs.GetDoc("policy/namespaces/list") + listCmd := newCommandFromDoc(listDoc, listAttributeNamespaces) + listCmd.Flags().StringP( + listDoc.GetDocFlag("state").Name, + listDoc.GetDocFlag("state").Shorthand, + listDoc.GetDocFlag("state").Default, + listDoc.GetDocFlag("state").Description, + ) + listCmd.Flags().Int32P( + listDoc.GetDocFlag("limit").Name, + listDoc.GetDocFlag("limit").Shorthand, + defaultListFlagLimit, + listDoc.GetDocFlag("limit").Description, + ) + listCmd.Flags().Int32P( + listDoc.GetDocFlag("offset").Name, + listDoc.GetDocFlag("offset").Shorthand, + defaultListFlagOffset, + listDoc.GetDocFlag("offset").Description, + ) + + createDoc := man.Docs.GetDoc("policy/namespaces/create") + createCmd := newCommandFromDoc(createDoc, createAttributeNamespace) + createCmd.Flags().StringP( + createDoc.GetDocFlag("name").Name, + createDoc.GetDocFlag("name").Shorthand, + createDoc.GetDocFlag("name").Default, + createDoc.GetDocFlag("name").Description, + ) + injectLabelFlags(createCmd, false) + + updateDoc := man.Docs.GetDoc("policy/namespaces/update") + updateCmd := newCommandFromDoc(updateDoc, updateAttributeNamespace) + updateCmd.Flags().StringP( + updateDoc.GetDocFlag("id").Name, + updateDoc.GetDocFlag("id").Shorthand, + updateDoc.GetDocFlag("id").Default, + updateDoc.GetDocFlag("id").Description, + ) + injectLabelFlags(updateCmd, true) + + deactivateDoc := man.Docs.GetDoc("policy/namespaces/deactivate") + deactivateCmd := newCommandFromDoc(deactivateDoc, deactivateAttributeNamespace) + deactivateCmd.Flags().StringP( + deactivateDoc.GetDocFlag("id").Name, + deactivateDoc.GetDocFlag("id").Shorthand, + deactivateDoc.GetDocFlag("id").Default, + deactivateDoc.GetDocFlag("id").Description, + ) + deactivateCmd.Flags().Bool( + deactivateDoc.GetDocFlag("force").Name, + false, + deactivateDoc.GetDocFlag("force").Description, + ) + + // unsafe + unsafeDoc := man.Docs.GetDoc("policy/namespaces/unsafe") + unsafeCmd := newCommandFromDoc(unsafeDoc, nil) + unsafeCmd.PersistentFlags().BoolVar( + &forceUnsafe, + unsafeDoc.GetDocFlag("force").Name, + false, + unsafeDoc.GetDocFlag("force").Description, + ) + + deleteDoc := man.Docs.GetDoc("policy/namespaces/unsafe/delete") + deleteCmd := newCommandFromDoc(deleteDoc, unsafeDeleteAttributeNamespace) + deleteCmd.Flags().StringP( + deleteDoc.GetDocFlag("id").Name, + deleteDoc.GetDocFlag("id").Shorthand, + deleteDoc.GetDocFlag("id").Default, + deleteDoc.GetDocFlag("id").Description, + ) + + reactivateDoc := man.Docs.GetDoc("policy/namespaces/unsafe/reactivate") + reactivateCmd := newCommandFromDoc(reactivateDoc, unsafeReactivateAttributeNamespace) + reactivateCmd.Flags().StringP( + reactivateDoc.GetDocFlag("id").Name, + reactivateDoc.GetDocFlag("id").Shorthand, + reactivateDoc.GetDocFlag("id").Default, + reactivateDoc.GetDocFlag("id").Description, + ) + + unsafeUpdateDoc := man.Docs.GetDoc("policy/namespaces/unsafe/update") + unsafeUpdateCmd := newCommandFromDoc(unsafeUpdateDoc, unsafeUpdateAttributeNamespace) + unsafeUpdateCmd.Flags().StringP( + unsafeUpdateDoc.GetDocFlag("id").Name, + unsafeUpdateDoc.GetDocFlag("id").Shorthand, + unsafeUpdateDoc.GetDocFlag("id").Default, + unsafeUpdateDoc.GetDocFlag("id").Description, + ) + unsafeUpdateCmd.Flags().StringP( + unsafeUpdateDoc.GetDocFlag("name").Name, + unsafeUpdateDoc.GetDocFlag("name").Shorthand, + unsafeUpdateDoc.GetDocFlag("name").Default, + unsafeUpdateDoc.GetDocFlag("name").Description, + ) + + // key + keyDoc := man.Docs.GetDoc("policy/namespaces/key") + keyCmd := newCommandFromDoc(keyDoc, nil) + + assignDoc := man.Docs.GetDoc("policy/namespaces/key/assign") + assignCmd := newCommandFromDoc(assignDoc, policyAssignKeyToNamespace) + assignCmd.Flags().StringP( + assignDoc.GetDocFlag("namespace").Name, + assignDoc.GetDocFlag("namespace").Shorthand, + assignDoc.GetDocFlag("namespace").Default, + assignDoc.GetDocFlag("namespace").Description, + ) + assignCmd.Flags().StringP( + assignDoc.GetDocFlag("key-id").Name, + assignDoc.GetDocFlag("key-id").Shorthand, + assignDoc.GetDocFlag("key-id").Default, + assignDoc.GetDocFlag("key-id").Description, + ) + + removeDoc := man.Docs.GetDoc("policy/namespaces/key/remove") + removeCmd := newCommandFromDoc(removeDoc, policyRemoveKeyFromNamespace) + removeCmd.Flags().StringP( + removeDoc.GetDocFlag("namespace").Name, + removeDoc.GetDocFlag("namespace").Shorthand, + removeDoc.GetDocFlag("namespace").Default, + removeDoc.GetDocFlag("namespace").Description, + ) + removeCmd.Flags().StringP( + removeDoc.GetDocFlag("key-id").Name, + removeDoc.GetDocFlag("key-id").Shorthand, + removeDoc.GetDocFlag("key-id").Default, + removeDoc.GetDocFlag("key-id").Description, + ) + + keyCmd.AddCommand(assignCmd, removeCmd) + unsafeCmd.AddCommand(deleteCmd, reactivateCmd, unsafeUpdateCmd) + nsCmd.AddCommand(getCmd, listCmd, createCmd, updateCmd, deactivateCmd, unsafeCmd, keyCmd) + + return nsCmd +} + +func initNamespacesCommands() { + Cmd.AddCommand(buildNamespacesCommandTree()) + AttributesCmd.AddCommand(buildNamespacesCommandTree()) +} diff --git a/otdfctl/cmd/policy/obligations.go b/otdfctl/cmd/policy/obligations.go new file mode 100644 index 0000000000..3d4e0b1148 --- /dev/null +++ b/otdfctl/cmd/policy/obligations.go @@ -0,0 +1,769 @@ +package policy + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/evertras/bubble-table/table" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/handlers" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/obligations" + "github.com/spf13/cobra" +) + +// +// Obligations +// + +var obligationValues []string + +// TriggerRequest represents the JSON structure for a trigger +type TriggerRequest struct { + Action string `json:"action"` + AttributeValue string `json:"attribute_value"` + Context *policy.RequestContext `json:"context,omitempty"` +} + +func policyCreateObligation(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + name := c.Flags.GetRequiredString("name") + obligationValues = c.Flags.GetStringSlice("value", obligationValues, cli.FlagsStringSliceOptions{}) + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + namespace := c.Flags.GetRequiredString("namespace") + obl, err := h.CreateObligation(cmd.Context(), namespace, name, obligationValues, getMetadataMutable(metadataLabels)) + if err != nil { + cli.ExitWithError("Failed to create obligation", err) + } + + simpleObligationValues := cli.GetSimpleObligationValues(obl.GetValues()) + + rows := [][]string{ + {"Id", obl.GetId()}, + {"Name", obl.GetName()}, + {"Values", cli.CommaSeparated(simpleObligationValues)}, + } + + if mdRows := getMetadataRows(obl.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, obl.GetId(), t, obl) +} + +func policyGetObligation(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetOptionalID("id") + fqn := c.Flags.GetOptionalString("fqn") + + obl, err := h.GetObligation(cmd.Context(), id, fqn) + if err != nil { + identifier := "id: " + id + if id == "" { + identifier = "fqn: " + fqn + } + errMsg := "Failed to find obligation (" + identifier + ")" + cli.ExitWithError(errMsg, err) + } + + simpleObligationValues := cli.GetSimpleObligationValues(obl.GetValues()) + + rows := [][]string{ + {"Id", obl.GetId()}, + {"Name", obl.GetName()}, + {"Values", cli.CommaSeparated(simpleObligationValues)}, + } + if mdRows := getMetadataRows(obl.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, obl.GetId(), t, obl) +} + +func policyListObligations(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + namespace := c.Flags.GetOptionalString("namespace") + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + + resp, err := h.ListObligations(cmd.Context(), limit, offset, namespace) + if err != nil { + cli.ExitWithError("Failed to list obligations", err) + } + + t := cli.NewTable( + cli.NewUUIDColumn(), + table.NewFlexColumn("name", "Name", cli.FlexColumnWidthFour), + table.NewFlexColumn("values", "Values", cli.FlexColumnWidthTwo), + ) + rows := []table.Row{} + for _, r := range resp.GetObligations() { + simpleObligationValues := cli.GetSimpleObligationValues(r.GetValues()) + rows = append(rows, table.NewRow(table.RowData{ + "id": r.GetId(), + "name": r.GetName(), + "values": cli.CommaSeparated(simpleObligationValues), + })) + } + t = t.WithRows(rows) + t = cli.WithListPaginationFooter(t, resp.GetPagination()) + common.HandleSuccess(cmd, "", t, resp) +} + +func policyUpdateObligation(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + name := c.Flags.GetOptionalString("name") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + updated, err := h.UpdateObligation( + cmd.Context(), + id, + name, + getMetadataMutable(metadataLabels), + getMetadataUpdateBehavior(), + ) + if err != nil { + cli.ExitWithError("Failed to update obligation", err) + } + + rows := [][]string{ + {"Id", id}, + {"Name", updated.GetName()}, + } + if mdRows := getMetadataRows(updated.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, updated) +} + +func policyDeleteObligation(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetOptionalID("id") + fqn := c.Flags.GetOptionalString("fqn") + + force := c.Flags.GetRequiredBool("force") + ctx := cmd.Context() + + obl, err := h.GetObligation(ctx, id, fqn) + identifier := id + if id == "" { + identifier = fqn + } + if err != nil { + errMsg := fmt.Sprintf("Failed to find obligation (%s)", identifier) + cli.ExitWithError(errMsg, err) + } + id = obl.GetId() + cli.ConfirmAction(cli.ActionDelete, "obligation", identifier, force) + + err = h.DeleteObligation(ctx, id, fqn) + if err != nil { + errMsg := fmt.Sprintf("Failed to delete obligation (%s)", id) + cli.ExitWithError(errMsg, err) + } + + rows := [][]string{ + {"Id", id}, + {"Name", obl.GetName()}, + } + if mdRows := getMetadataRows(obl.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, obl) +} + +// +// Obligation Values +// + +func policyCreateObligationValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + obligation := c.Flags.GetRequiredString("obligation") + value := c.Flags.GetRequiredString("value") + triggerJSON := c.Flags.GetOptionalString("triggers") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + // Parse triggers if provided + triggers, err := parseTriggers(triggerJSON) + if err != nil { + cli.ExitWithError("Invalid trigger configuration", err) + } + + oblVal, err := h.CreateObligationValue(ctx, obligation, value, triggers, getMetadataMutable(metadataLabels)) + if err != nil { + cli.ExitWithError("Failed to create obligation value", err) + } + + rows := [][]string{ + {"Id", oblVal.GetId()}, + {"Name", oblVal.GetObligation().GetName()}, + {"Value", oblVal.GetValue()}, + {"Number of Triggers", strconv.Itoa(len(oblVal.GetTriggers()))}, + } + if mdRows := getMetadataRows(oblVal.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, oblVal.GetId(), t, oblVal) +} + +func policyGetObligationValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetOptionalID("id") + fqn := c.Flags.GetOptionalString("fqn") + + value, err := h.GetObligationValue(cmd.Context(), id, fqn) + if err != nil { + identifier := "id: " + id + if id == "" { + identifier = "fqn: " + fqn + } + errMsg := "Failed to find obligation value (" + identifier + ")" + cli.ExitWithError(errMsg, err) + } + + rows := [][]string{ + {"Id", value.GetId()}, + {"Name", value.GetObligation().GetName()}, + {"Value", value.GetValue()}, + {"Number of Triggers", strconv.Itoa(len(value.GetTriggers()))}, + } + if mdRows := getMetadataRows(value.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, value.GetId(), t, value) +} + +func policyUpdateObligationValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + value := c.Flags.GetOptionalString("value") + triggerJSON := c.Flags.GetOptionalString("triggers") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + // Parse triggers if provided + triggers, err := parseTriggers(triggerJSON) + if err != nil { + cli.ExitWithError("Invalid trigger configuration", err) + } + + updated, err := h.UpdateObligationValue( + cmd.Context(), + id, + value, + triggers, + getMetadataMutable(metadataLabels), + getMetadataUpdateBehavior(), + ) + if err != nil { + cli.ExitWithError("Failed to update obligation value", err) + } + + rows := [][]string{ + {"Id", id}, + {"Name", updated.GetObligation().GetName()}, + {"Value", updated.GetValue()}, + {"Number of Triggers", strconv.Itoa(len(updated.GetTriggers()))}, + } + if mdRows := getMetadataRows(updated.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, updated) +} + +func policyDeleteObligationValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetOptionalID("id") + fqn := c.Flags.GetOptionalString("fqn") + + force := c.Flags.GetOptionalBool("force") + ctx := cmd.Context() + + val, err := h.GetObligationValue(ctx, id, fqn) + identifier := id + if id == "" { + identifier = fqn + } + if err != nil { + errMsg := fmt.Sprintf("Failed to find obligation value (%s)", identifier) + cli.ExitWithError(errMsg, err) + } + id = val.GetId() + cli.ConfirmAction(cli.ActionDelete, "obligation value", identifier, force) + + err = h.DeleteObligationValue(ctx, id, fqn) + if err != nil { + errMsg := fmt.Sprintf("Failed to delete obligation value (%s)", id) + cli.ExitWithError(errMsg, err) + } + + rows := [][]string{ + {"Id", id}, + {"Value", val.GetValue()}, + } + if mdRows := getMetadataRows(val.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, val) +} + +// **** +// Obligation Triggers +// **** +func policyCreateObligationTrigger(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + attributeValue := c.Flags.GetRequiredString("attribute-value") + action := c.Flags.GetRequiredString("action") + obligationValue := c.Flags.GetRequiredString("obligation-value") + clientID := c.Flags.GetOptionalString("client-id") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + trigger, err := h.CreateObligationTrigger(ctx, attributeValue, action, obligationValue, clientID, getMetadataMutable(metadataLabels)) + if err != nil { + cli.ExitWithError("Failed to create obligation trigger", err) + } + + rows := getObligationTriggerRows(trigger) + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, trigger.GetId(), t, trigger) +} + +func policyDeleteObligationTrigger(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredString("id") + force := c.Flags.GetOptionalBool("force") + ctx := cmd.Context() + + cli.ConfirmAction(cli.ActionDelete, "obligation trigger", id, force) + + resp, err := h.DeleteObligationTrigger(ctx, id) + if err != nil { + errMsg := fmt.Sprintf("Failed to delete obligation trigger (%s)", id) + cli.ExitWithError(errMsg, err) + } + + rows := [][]string{ + {"Id", id}, + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, resp) +} + +func policyListObligationTriggers(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + namespace := c.Flags.GetOptionalString("namespace") + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + + resp, err := h.ListObligationTriggers(cmd.Context(), namespace, limit, offset) + if err != nil { + cli.ExitWithError("Failed to list obligation triggers", err) + } + + t := cli.NewTable( + cli.NewUUIDColumn(), + table.NewFlexColumn("attribute", "Attribute Value FQN", cli.FlexColumnWidthThree), + table.NewFlexColumn("action", "Action", cli.FlexColumnWidthOne), + table.NewFlexColumn("obligation", "Obligation Value FQN", cli.FlexColumnWidthThree), + table.NewFlexColumn("client_ids", "Client IDs", cli.FlexColumnWidthOne), + ) + rows := []table.Row{} + for _, r := range resp.GetTriggers() { + rows = append(rows, table.NewRow(table.RowData{ + "id": r.GetId(), + "attribute": r.GetAttributeValue().GetFqn(), + "action": r.GetAction().GetName(), + "obligation": r.GetObligationValue().GetFqn(), + "client_ids": cli.CommaSeparated(cli.AggregateClientIDs(r.GetContext())), + })) + } + t = t.WithRows(rows) + t = cli.WithListPaginationFooter(t, resp.GetPagination()) + common.HandleSuccess(cmd, "", t, resp) +} + +func getObligationTriggerRows(trigger *policy.ObligationTrigger) [][]string { + rows := [][]string{ + {"Id", trigger.GetId()}, + {"Attribute Value FQN", trigger.GetAttributeValue().GetFqn()}, + {"Action", trigger.GetAction().GetName()}, + {"Obligation Value FQN", trigger.GetObligationValue().GetFqn()}, + {"Client IDs", cli.CommaSeparated(cli.AggregateClientIDs(trigger.GetContext()))}, + } + if mdRows := getMetadataRows(trigger.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + return rows +} + +// parseTriggers unmarshals the trigger JSON string or reads from file and validates required fields +func parseTriggers(triggerInput string) ([]*obligations.ValueTriggerRequest, error) { + if triggerInput == "" { + return nil, nil + } + + // Determine if input is a file path or JSON string + triggerJSON, err := cli.GetJSONInput(triggerInput) + if err != nil { + return nil, fmt.Errorf("failed to get JSON input: %w", err) + } + + var triggerRequests []TriggerRequest + if err := json.Unmarshal([]byte(triggerJSON), &triggerRequests); err != nil { + return nil, fmt.Errorf("failed to parse trigger JSON: %w", err) + } + + var valueTriggerRequests []*obligations.ValueTriggerRequest + for i, tr := range triggerRequests { + // Validate required fields + if strings.TrimSpace(tr.Action) == "" { + return nil, fmt.Errorf("trigger at index %d: action is required", i) + } + if strings.TrimSpace(tr.AttributeValue) == "" { + return nil, fmt.Errorf("trigger at index %d: attribute_value is required", i) + } + + // Create the ValueTriggerRequest + valueTrigger := &obligations.ValueTriggerRequest{ + Action: handlers.ParseToIDNameIdentifier(tr.Action), + AttributeValue: handlers.ParseToIDFqnIdentifier(tr.AttributeValue), + } + + // Add context if client_id is provided + if tr.Context != nil { + valueTrigger.Context = tr.Context + } + + valueTriggerRequests = append(valueTriggerRequests, valueTrigger) + } + + return valueTriggerRequests, nil +} + +func initObligationsCommands() { + // Obligations commands + getDoc := man.Docs.GetCommand("policy/obligations/get", + man.WithRun(policyGetObligation), + ) + getDoc.Flags().StringP( + getDoc.GetDocFlag("id").Name, + getDoc.GetDocFlag("id").Shorthand, + getDoc.GetDocFlag("id").Default, + getDoc.GetDocFlag("id").Description, + ) + getDoc.Flags().StringP( + getDoc.GetDocFlag("fqn").Name, + getDoc.GetDocFlag("fqn").Shorthand, + getDoc.GetDocFlag("fqn").Default, + getDoc.GetDocFlag("fqn").Description, + ) + getDoc.MarkFlagsMutuallyExclusive("id", "fqn") + getDoc.MarkFlagsOneRequired("id", "fqn") + + listDoc := man.Docs.GetCommand("policy/obligations/list", + man.WithRun(policyListObligations), + ) + listDoc.Flags().StringP( + listDoc.GetDocFlag("namespace").Name, + listDoc.GetDocFlag("namespace").Shorthand, + listDoc.GetDocFlag("namespace").Default, + listDoc.GetDocFlag("namespace").Description, + ) + injectListPaginationFlags(listDoc) + + createDoc := man.Docs.GetCommand("policy/obligations/create", + man.WithRun(policyCreateObligation), + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("name").Name, + createDoc.GetDocFlag("name").Shorthand, + createDoc.GetDocFlag("name").Default, + createDoc.GetDocFlag("name").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("namespace").Name, + createDoc.GetDocFlag("namespace").Shorthand, + createDoc.GetDocFlag("namespace").Default, + createDoc.GetDocFlag("namespace").Description, + ) + createDoc.Flags().StringSliceVarP( + &obligationValues, + createDoc.GetDocFlag("value").Name, + createDoc.GetDocFlag("value").Shorthand, + []string{}, + createDoc.GetDocFlag("value").Description, + ) + injectLabelFlags(&createDoc.Command, false) + + updateDoc := man.Docs.GetCommand("policy/obligations/update", + man.WithRun(policyUpdateObligation), + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("id").Name, + updateDoc.GetDocFlag("id").Shorthand, + updateDoc.GetDocFlag("id").Default, + updateDoc.GetDocFlag("id").Description, + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("name").Name, + updateDoc.GetDocFlag("name").Shorthand, + updateDoc.GetDocFlag("name").Default, + updateDoc.GetDocFlag("name").Description, + ) + injectLabelFlags(&updateDoc.Command, true) + + deleteDoc := man.Docs.GetCommand("policy/obligations/delete", + man.WithRun(policyDeleteObligation), + ) + deleteDoc.Flags().StringP( + deleteDoc.GetDocFlag("id").Name, + deleteDoc.GetDocFlag("id").Shorthand, + deleteDoc.GetDocFlag("id").Default, + deleteDoc.GetDocFlag("id").Description, + ) + deleteDoc.Flags().StringP( + deleteDoc.GetDocFlag("fqn").Name, + deleteDoc.GetDocFlag("fqn").Shorthand, + deleteDoc.GetDocFlag("fqn").Default, + deleteDoc.GetDocFlag("fqn").Description, + ) + deleteDoc.Flags().Bool( + deleteDoc.GetDocFlag("force").Name, + false, + deleteDoc.GetDocFlag("force").Description, + ) + deleteDoc.MarkFlagsMutuallyExclusive("id", "fqn") + deleteDoc.MarkFlagsOneRequired("id", "fqn") + + // Obligation Values commands + + getValueDoc := man.Docs.GetCommand("policy/obligations/values/get", + man.WithRun(policyGetObligationValue), + ) + getValueDoc.Flags().StringP( + getValueDoc.GetDocFlag("id").Name, + getValueDoc.GetDocFlag("id").Shorthand, + getValueDoc.GetDocFlag("id").Default, + getValueDoc.GetDocFlag("id").Description, + ) + getValueDoc.Flags().StringP( + getValueDoc.GetDocFlag("fqn").Name, + getValueDoc.GetDocFlag("fqn").Shorthand, + getValueDoc.GetDocFlag("fqn").Default, + getValueDoc.GetDocFlag("fqn").Description, + ) + getValueDoc.MarkFlagsMutuallyExclusive("id", "fqn") + getValueDoc.MarkFlagsOneRequired("id", "fqn") + + createValueDoc := man.Docs.GetCommand("policy/obligations/values/create", + man.WithRun(policyCreateObligationValue), + ) + createValueDoc.Flags().StringP( + createValueDoc.GetDocFlag("obligation").Name, + createValueDoc.GetDocFlag("obligation").Shorthand, + createValueDoc.GetDocFlag("obligation").Default, + createValueDoc.GetDocFlag("obligation").Description, + ) + createValueDoc.Flags().StringP( + createValueDoc.GetDocFlag("value").Name, + createValueDoc.GetDocFlag("value").Shorthand, + createValueDoc.GetDocFlag("value").Default, + createValueDoc.GetDocFlag("value").Description, + ) + createValueDoc.Flags().StringP( + createValueDoc.GetDocFlag("triggers").Name, + createValueDoc.GetDocFlag("triggers").Shorthand, + createValueDoc.GetDocFlag("triggers").Default, + createValueDoc.GetDocFlag("triggers").Description, + ) + injectLabelFlags(&createValueDoc.Command, false) + + updateValueDoc := man.Docs.GetCommand("policy/obligations/values/update", + man.WithRun(policyUpdateObligationValue), + ) + updateValueDoc.Flags().StringP( + updateDoc.GetDocFlag("id").Name, + updateDoc.GetDocFlag("id").Shorthand, + updateDoc.GetDocFlag("id").Default, + updateDoc.GetDocFlag("id").Description, + ) + updateValueDoc.Flags().StringP( + updateValueDoc.GetDocFlag("value").Name, + updateValueDoc.GetDocFlag("value").Shorthand, + updateValueDoc.GetDocFlag("value").Default, + updateValueDoc.GetDocFlag("value").Description, + ) + updateValueDoc.Flags().StringP( + updateValueDoc.GetDocFlag("triggers").Name, + updateValueDoc.GetDocFlag("triggers").Shorthand, + updateValueDoc.GetDocFlag("triggers").Default, + updateValueDoc.GetDocFlag("triggers").Description, + ) + injectLabelFlags(&updateValueDoc.Command, true) + deleteValueDoc := man.Docs.GetCommand("policy/obligations/values/delete", + man.WithRun(policyDeleteObligationValue), + ) + deleteValueDoc.Flags().StringP( + deleteValueDoc.GetDocFlag("id").Name, + deleteValueDoc.GetDocFlag("id").Shorthand, + deleteValueDoc.GetDocFlag("id").Default, + deleteValueDoc.GetDocFlag("id").Description, + ) + deleteValueDoc.Flags().StringP( + deleteValueDoc.GetDocFlag("fqn").Name, + deleteValueDoc.GetDocFlag("fqn").Shorthand, + deleteValueDoc.GetDocFlag("fqn").Default, + deleteValueDoc.GetDocFlag("fqn").Description, + ) + deleteValueDoc.Flags().Bool( + deleteValueDoc.GetDocFlag("force").Name, + false, + deleteValueDoc.GetDocFlag("force").Description, + ) + deleteValueDoc.MarkFlagsMutuallyExclusive("id", "fqn") + deleteValueDoc.MarkFlagsOneRequired("id", "fqn") + + // Obligation Triggers commands + createTriggerDoc := man.Docs.GetCommand("policy/obligations/triggers/create", + man.WithRun(policyCreateObligationTrigger), + ) + createTriggerDoc.Flags().StringP( + createTriggerDoc.GetDocFlag("attribute-value").Name, + createTriggerDoc.GetDocFlag("attribute-value").Shorthand, + createTriggerDoc.GetDocFlag("attribute-value").Default, + createTriggerDoc.GetDocFlag("attribute-value").Description, + ) + createTriggerDoc.Flags().StringP( + createTriggerDoc.GetDocFlag("action").Name, + createTriggerDoc.GetDocFlag("action").Shorthand, + createTriggerDoc.GetDocFlag("action").Default, + createTriggerDoc.GetDocFlag("action").Description, + ) + createTriggerDoc.Flags().StringP( + createTriggerDoc.GetDocFlag("obligation-value").Name, + createTriggerDoc.GetDocFlag("obligation-value").Shorthand, + createTriggerDoc.GetDocFlag("obligation-value").Default, + createTriggerDoc.GetDocFlag("obligation-value").Description, + ) + createTriggerDoc.Flags().StringP( + createTriggerDoc.GetDocFlag("client-id").Name, + createTriggerDoc.GetDocFlag("client-id").Shorthand, + createTriggerDoc.GetDocFlag("client-id").Default, + createTriggerDoc.GetDocFlag("client-id").Description, + ) + injectLabelFlags(&createTriggerDoc.Command, false) + + deleteTriggerDoc := man.Docs.GetCommand("policy/obligations/triggers/delete", + man.WithRun(policyDeleteObligationTrigger), + ) + deleteTriggerDoc.Flags().StringP( + deleteTriggerDoc.GetDocFlag("id").Name, + deleteTriggerDoc.GetDocFlag("id").Shorthand, + deleteTriggerDoc.GetDocFlag("id").Default, + deleteTriggerDoc.GetDocFlag("id").Description, + ) + deleteTriggerDoc.Flags().Bool( + deleteTriggerDoc.GetDocFlag("force").Name, + false, + deleteTriggerDoc.GetDocFlag("force").Description, + ) + + listTriggerDoc := man.Docs.GetCommand("policy/obligations/triggers/list", + man.WithRun(policyListObligationTriggers), + ) + listTriggerDoc.Flags().StringP( + listTriggerDoc.GetDocFlag("namespace").Name, + listTriggerDoc.GetDocFlag("namespace").Shorthand, + listTriggerDoc.GetDocFlag("namespace").Default, + listTriggerDoc.GetDocFlag("namespace").Description, + ) + injectListPaginationFlags(listTriggerDoc) + + // Add commands to the policy command + + policyObligationsDoc := man.Docs.GetCommand("policy/obligations", + man.WithSubcommands( + getDoc, + listDoc, + createDoc, + updateDoc, + deleteDoc, + ), + ) + + policyObligationValuesDoc := man.Docs.GetCommand("policy/obligations/values", + man.WithSubcommands( + getValueDoc, + createValueDoc, + updateValueDoc, + deleteValueDoc, + ), + ) + + policyObligationTriggersDoc := man.Docs.GetCommand("policy/obligations/triggers", + man.WithSubcommands( + createTriggerDoc, + deleteTriggerDoc, + listTriggerDoc, + ), + ) + + policyObligationsDoc.AddCommand(&policyObligationValuesDoc.Command) + policyObligationsDoc.AddCommand(&policyObligationTriggersDoc.Command) + Cmd.AddCommand(&policyObligationsDoc.Command) +} diff --git a/otdfctl/cmd/policy/policy.go b/otdfctl/cmd/policy/policy.go new file mode 100644 index 0000000000..28d2ab5e31 --- /dev/null +++ b/otdfctl/cmd/policy/policy.go @@ -0,0 +1,105 @@ +package policy + +import ( + "strings" + + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/protocol/go/common" + "github.com/spf13/cobra" +) + +var ( + metadataLabels []string + defaultListFlagLimit int32 = 300 + defaultListFlagOffset int32 + + Cmd = &cobra.Command{ + Use: man.Docs.GetDoc("policy").Use, + Short: man.Docs.GetDoc("policy").Short, + Long: man.Docs.GetDoc("policy").Long, + } +) + +func getMetadataRows(m *common.Metadata) [][]string { + if m != nil { + metadata := cli.ConstructMetadata(m) + metadataRows := [][]string{ + {"Created At", metadata["Created At"]}, + {"Updated At", metadata["Updated At"]}, + } + if m.Labels != nil { + metadataRows = append(metadataRows, []string{"Labels", metadata["Labels"]}) + } + return metadataRows + } + return nil +} + +const keyValLength = 2 + +func getMetadataMutable(labels []string) *common.MetadataMutable { + metadata := common.MetadataMutable{} + if len(labels) > 0 { + metadata.Labels = map[string]string{} + for _, label := range labels { + kv := strings.Split(label, "=") + if len(kv) != keyValLength { + cli.ExitWithError("Invalid label format", nil) + } + metadata.Labels[kv[0]] = kv[1] + } + return &metadata + } + return nil +} + +func getMetadataUpdateBehavior() common.MetadataUpdateEnum { + if forceReplaceMetadataLabels { + return common.MetadataUpdateEnum_METADATA_UPDATE_ENUM_REPLACE + } + return common.MetadataUpdateEnum_METADATA_UPDATE_ENUM_EXTEND +} + +// Adds reusable create/update label flags to a Policy command and the optional force-replace-labels flag for updates only +func injectLabelFlags(cmd *cobra.Command, isUpdate bool) { + cmd.Flags().StringSliceVarP(&metadataLabels, "label", "l", []string{}, "Optional metadata 'labels' in the format: key=value") + if isUpdate { + cmd.Flags().BoolVar(&forceReplaceMetadataLabels, "force-replace-labels", false, "Destructively replace entire set of existing metadata 'labels' with any provided to this command") + } +} + +// Adds reusable limit/offset flags to a Policy LIST command +func injectListPaginationFlags(listDoc *man.Doc) { + listDoc.Flags().Int32P( + listDoc.GetDocFlag("limit").Name, + listDoc.GetDocFlag("limit").Shorthand, + defaultListFlagLimit, + listDoc.GetDocFlag("limit").Description, + ) + listDoc.Flags().Int32P( + listDoc.GetDocFlag("offset").Name, + listDoc.GetDocFlag("offset").Shorthand, + defaultListFlagOffset, + listDoc.GetDocFlag("offset").Description, + ) +} + +func InitCommands() { + initActionsCommands() + initAttributesCommands() + initAttributeValuesCommands() + initNamespacesCommands() + initSubjectConditionSetsCommands() + initSubjectMappingsCommands() + initObligationsCommands() + initResourceMappingsCommands() + initResourceMappingGroupsCommands() + initRegisteredResourcesCommands() + initKeyManagementCommands() + initKeyManagementProviderCommands() + initKASRegistryCommands() + initKASKeysCommands() + initKASGrantsCommands() + initBaseKeysCommands() +} diff --git a/otdfctl/cmd/policy/registeredResources.go b/otdfctl/cmd/policy/registeredResources.go new file mode 100644 index 0000000000..e33cf7cd04 --- /dev/null +++ b/otdfctl/cmd/policy/registeredResources.go @@ -0,0 +1,685 @@ +package policy + +import ( + "fmt" + "strings" + + "github.com/evertras/bubble-table/table" + "github.com/google/uuid" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/protocol/go/policy/registeredresources" + "github.com/spf13/cobra" +) + +var ( + registeredResourceValues []string + actionAttributeValues []string +) + +const actionAttributeValueArgSplitCount = 2 + +// +// Registered Resources +// + +func policyCreateRegisteredResource(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + name := c.Flags.GetRequiredString("name") + namespace := c.Flags.GetOptionalString("namespace") + registeredResourceValues = c.Flags.GetStringSlice("value", registeredResourceValues, cli.FlagsStringSliceOptions{}) + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + resource, err := h.CreateRegisteredResource(cmd.Context(), namespace, name, registeredResourceValues, getMetadataMutable(metadataLabels)) + if err != nil { + cli.ExitWithError("Failed to create registered resource", err) + } + + simpleRegResValues := cli.GetSimpleRegisteredResourceValues(resource.GetValues()) + + rows := [][]string{ + {"Id", resource.GetId()}, + {"Name", resource.GetName()}, + {"Namespace", resource.GetNamespace().GetFqn()}, + {"Values", cli.CommaSeparated(simpleRegResValues)}, + } + + if mdRows := getMetadataRows(resource.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, resource.GetId(), t, resource) +} + +func policyGetRegisteredResource(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetOptionalID("id") + name := c.Flags.GetOptionalString("name") + namespace := c.Flags.GetOptionalString("namespace") + + if id == "" && name == "" { + cli.ExitWithError("Either 'id' or 'name' must be provided", nil) + } + + resource, err := h.GetRegisteredResource(cmd.Context(), id, name, namespace) + if err != nil { + identifier := "id: " + id + if id == "" { + identifier = "name: " + name + } + errMsg := "Failed to find registered resource (" + identifier + ")" + cli.ExitWithError(errMsg, err) + } + + simpleRegResValues := cli.GetSimpleRegisteredResourceValues(resource.GetValues()) + + rows := [][]string{ + {"Id", resource.GetId()}, + {"Name", resource.GetName()}, + {"Namespace", resource.GetNamespace().GetFqn()}, + {"Values", cli.CommaSeparated(simpleRegResValues)}, + } + if mdRows := getMetadataRows(resource.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, resource.GetId(), t, resource) +} + +func policyListRegisteredResources(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + namespace := c.Flags.GetOptionalString("namespace") + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + + resp, err := h.ListRegisteredResources(cmd.Context(), limit, offset, namespace) + if err != nil { + cli.ExitWithError("Failed to list registered resources", err) + } + + t := cli.NewTable( + cli.NewUUIDColumn(), + table.NewFlexColumn("name", "Name", cli.FlexColumnWidthFour), + table.NewFlexColumn("namespace", "Namespace", cli.FlexColumnWidthFour), + table.NewFlexColumn("values", "Values", cli.FlexColumnWidthTwo), + ) + rows := []table.Row{} + for _, r := range resp.GetResources() { + simpleRegResValues := cli.GetSimpleRegisteredResourceValues(r.GetValues()) + rows = append(rows, table.NewRow(table.RowData{ + "id": r.GetId(), + "name": r.GetName(), + "namespace": r.GetNamespace().GetFqn(), + "values": cli.CommaSeparated(simpleRegResValues), + })) + } + t = t.WithRows(rows) + t = cli.WithListPaginationFooter(t, resp.GetPagination()) + common.HandleSuccess(cmd, "", t, resp) +} + +func policyUpdateRegisteredResource(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + name := c.Flags.GetOptionalString("name") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + updated, err := h.UpdateRegisteredResource( + cmd.Context(), + id, + name, + getMetadataMutable(metadataLabels), + getMetadataUpdateBehavior(), + ) + if err != nil { + cli.ExitWithError("Failed to update registered resource", err) + } + + rows := [][]string{ + {"Id", id}, + {"Name", updated.GetName()}, + {"Namespace", updated.GetNamespace().GetFqn()}, + } + if mdRows := getMetadataRows(updated.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, updated) +} + +func policyDeleteRegisteredResource(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + force := c.Flags.GetRequiredBool("force") + ctx := cmd.Context() + + resource, err := h.GetRegisteredResource(ctx, id, "", "") + if err != nil { + errMsg := fmt.Sprintf("Failed to find registered resource (%s)", id) + cli.ExitWithError(errMsg, err) + } + + cli.ConfirmAction(cli.ActionDelete, "registered resource", id, force) + + err = h.DeleteRegisteredResource(ctx, id) + if err != nil { + errMsg := fmt.Sprintf("Failed to delete registered resource (%s)", id) + cli.ExitWithError(errMsg, err) + } + + rows := [][]string{ + {"Id", id}, + {"Name", resource.GetName()}, + {"Namespace", resource.GetNamespace().GetFqn()}, + } + if mdRows := getMetadataRows(resource.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, resource) +} + +// +// Registered Resource Values +// + +func policyCreateRegisteredResourceValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + resource := c.Flags.GetRequiredString("resource") + value := c.Flags.GetRequiredString("value") + actionAttributeValues = c.Flags.GetStringSlice("action-attribute-value", actionAttributeValues, cli.FlagsStringSliceOptions{Min: 0}) + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + namespace := c.Flags.GetOptionalString("namespace") + + var resourceID string + if uuid.Validate(resource) == nil { + resourceID = resource + } else { + resourceByName, err := h.GetRegisteredResource(ctx, "", resource, namespace) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to find registered resource (name: %s)", resource), err) + } + resourceID = resourceByName.GetId() + } + + parsedActionAttributeValues := parseActionAttributeValueArgs(actionAttributeValues) + + resourceValue, err := h.CreateRegisteredResourceValue(ctx, resourceID, value, parsedActionAttributeValues, getMetadataMutable(metadataLabels)) + if err != nil { + cli.ExitWithError("Failed to create registered resource value", err) + } + + simpleActionAttributeValues := cli.GetSimpleRegisteredResourceActionAttributeValues(resourceValue.GetActionAttributeValues()) + + rows := [][]string{ + {"Id", resourceValue.GetId()}, + {"Value", resourceValue.GetValue()}, + {"Action Attribute Values", cli.CommaSeparated(simpleActionAttributeValues)}, + } + if mdRows := getMetadataRows(resourceValue.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, resourceValue.GetId(), t, resourceValue) +} + +func policyGetRegisteredResourceValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetOptionalID("id") + fqn := c.Flags.GetOptionalString("fqn") + + if id == "" && fqn == "" { + cli.ExitWithError("Either 'id' or 'fqn' must be provided", nil) + } + + value, err := h.GetRegisteredResourceValue(cmd.Context(), id, fqn) + if err != nil { + identifier := "id: " + id + if id == "" { + identifier = "fqn: " + fqn + } + errMsg := "Failed to find registered resource value (" + identifier + ")" + cli.ExitWithError(errMsg, err) + } + + simpleActionAttributeValues := cli.GetSimpleRegisteredResourceActionAttributeValues(value.GetActionAttributeValues()) + + rows := [][]string{ + {"Id", value.GetId()}, + {"Value", value.GetValue()}, + {"Action Attribute Values", cli.CommaSeparated(simpleActionAttributeValues)}, + } + if mdRows := getMetadataRows(value.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, value.GetId(), t, value) +} + +func policyListRegisteredResourceValues(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + resource := c.Flags.GetRequiredString("resource") + namespace := c.Flags.GetOptionalString("namespace") + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + + var resourceID string + if uuid.Validate(resource) == nil { + resourceID = resource + } else { + resourceByName, err := h.GetRegisteredResource(ctx, "", resource, namespace) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to find registered resource (name: %s)", resource), err) + } + resourceID = resourceByName.GetId() + } + + resp, err := h.ListRegisteredResourceValues(ctx, resourceID, limit, offset) + if err != nil { + cli.ExitWithError("Failed to list registered resource values", err) + } + + t := cli.NewTable( + cli.NewUUIDColumn(), + table.NewFlexColumn("value", "Value", cli.FlexColumnWidthFour), + table.NewFlexColumn("action-attribute-values", "Action Attribute Values", cli.FlexColumnWidthFour), + ) + rows := []table.Row{} + for _, v := range resp.GetValues() { + simpleActionAttributeValues := cli.GetSimpleRegisteredResourceActionAttributeValues(v.GetActionAttributeValues()) + + rows = append(rows, table.NewRow(table.RowData{ + "id": v.GetId(), + "value": v.GetValue(), + "action-attribute-values": cli.CommaSeparated(simpleActionAttributeValues), + })) + } + + t = t.WithRows(rows) + t = cli.WithListPaginationFooter(t, resp.GetPagination()) + common.HandleSuccess(cmd, "", t, resp) +} + +func policyUpdateRegisteredResourceValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + value := c.Flags.GetOptionalString("value") + actionAttributeValues = c.Flags.GetStringSlice("action-attribute-value", actionAttributeValues, cli.FlagsStringSliceOptions{Min: 0}) + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + force := c.Flags.GetOptionalBool("force") + + parsedActionAttributeValues := parseActionAttributeValueArgs(actionAttributeValues) + + // only confirm if new action attribute values provided + if len(parsedActionAttributeValues) > 0 { + cli.ConfirmActionSubtext(cli.ActionUpdate, "registered resource value", id, + "All existing action attribute values will be replaced with the new ones provided.", + force) + } + + updated, err := h.UpdateRegisteredResourceValue( + cmd.Context(), + id, + value, + parsedActionAttributeValues, + getMetadataMutable(metadataLabels), + getMetadataUpdateBehavior(), + ) + if err != nil { + cli.ExitWithError("Failed to update registered resource value", err) + } + + simpleActionAttributeValues := cli.GetSimpleRegisteredResourceActionAttributeValues(updated.GetActionAttributeValues()) + + rows := [][]string{ + {"Id", id}, + {"Value", updated.GetValue()}, + {"Action Attribute Values", cli.CommaSeparated(simpleActionAttributeValues)}, + } + if mdRows := getMetadataRows(updated.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, updated) +} + +func policyDeleteRegisteredResourceValue(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + force := c.Flags.GetOptionalBool("force") + ctx := cmd.Context() + + resource, err := h.GetRegisteredResourceValue(ctx, id, "") + if err != nil { + errMsg := fmt.Sprintf("Failed to find registered resource value (%s)", id) + cli.ExitWithError(errMsg, err) + } + + cli.ConfirmAction(cli.ActionDelete, "registered resource value", id, force) + + err = h.DeleteRegisteredResourceValue(ctx, id) + if err != nil { + errMsg := fmt.Sprintf("Failed to delete registered resource value (%s)", id) + cli.ExitWithError(errMsg, err) + } + + rows := [][]string{ + {"Id", id}, + {"Value", resource.GetValue()}, + } + if mdRows := getMetadataRows(resource.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, resource) +} + +func parseActionAttributeValueArgs(args []string) []*registeredresources.ActionAttributeValue { + parsed := make([]*registeredresources.ActionAttributeValue, len(args)) + + for i, a := range args { + // split on semicolon + split := strings.Split(a, ";") + if len(split) != actionAttributeValueArgSplitCount { + cli.ExitWithError("Invalid action attribute value arg format", nil) + } + + actionIdentifier := split[0] + attrValIdentifier := split[1] + + newActionAttrVal := ®isteredresources.ActionAttributeValue{} + + if uuid.Validate(actionIdentifier) == nil { + newActionAttrVal.ActionIdentifier = ®isteredresources.ActionAttributeValue_ActionId{ + ActionId: actionIdentifier, + } + } else { + newActionAttrVal.ActionIdentifier = ®isteredresources.ActionAttributeValue_ActionName{ + ActionName: actionIdentifier, + } + } + + if uuid.Validate(attrValIdentifier) == nil { + newActionAttrVal.AttributeValueIdentifier = ®isteredresources.ActionAttributeValue_AttributeValueId{ + AttributeValueId: attrValIdentifier, + } + } else { + newActionAttrVal.AttributeValueIdentifier = ®isteredresources.ActionAttributeValue_AttributeValueFqn{ + AttributeValueFqn: attrValIdentifier, + } + } + + parsed[i] = newActionAttrVal + } + + return parsed +} + +func initRegisteredResourcesCommands() { + // Registered Resources commands + + getDoc := man.Docs.GetCommand("policy/registered-resources/get", + man.WithRun(policyGetRegisteredResource), + ) + getDoc.Flags().StringP( + getDoc.GetDocFlag("id").Name, + getDoc.GetDocFlag("id").Shorthand, + getDoc.GetDocFlag("id").Default, + getDoc.GetDocFlag("id").Description, + ) + getDoc.Flags().StringP( + getDoc.GetDocFlag("name").Name, + getDoc.GetDocFlag("name").Shorthand, + getDoc.GetDocFlag("name").Default, + getDoc.GetDocFlag("name").Description, + ) + getDoc.Flags().StringP( + getDoc.GetDocFlag("namespace").Name, + getDoc.GetDocFlag("namespace").Shorthand, + getDoc.GetDocFlag("namespace").Default, + getDoc.GetDocFlag("namespace").Description, + ) + + listDoc := man.Docs.GetCommand("policy/registered-resources/list", + man.WithRun(policyListRegisteredResources), + ) + listDoc.Flags().StringP( + listDoc.GetDocFlag("namespace").Name, + listDoc.GetDocFlag("namespace").Shorthand, + listDoc.GetDocFlag("namespace").Default, + listDoc.GetDocFlag("namespace").Description, + ) + injectListPaginationFlags(listDoc) + + createDoc := man.Docs.GetCommand("policy/registered-resources/create", + man.WithRun(policyCreateRegisteredResource), + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("name").Name, + createDoc.GetDocFlag("name").Shorthand, + createDoc.GetDocFlag("name").Default, + createDoc.GetDocFlag("name").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("namespace").Name, + createDoc.GetDocFlag("namespace").Shorthand, + createDoc.GetDocFlag("namespace").Default, + createDoc.GetDocFlag("namespace").Description, + ) + createDoc.Flags().StringSliceVarP( + ®isteredResourceValues, + createDoc.GetDocFlag("value").Name, + createDoc.GetDocFlag("value").Shorthand, + []string{}, + createDoc.GetDocFlag("value").Description, + ) + injectLabelFlags(&createDoc.Command, false) + + updateDoc := man.Docs.GetCommand("policy/registered-resources/update", + man.WithRun(policyUpdateRegisteredResource), + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("id").Name, + updateDoc.GetDocFlag("id").Shorthand, + updateDoc.GetDocFlag("id").Default, + updateDoc.GetDocFlag("id").Description, + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("name").Name, + updateDoc.GetDocFlag("name").Shorthand, + updateDoc.GetDocFlag("name").Default, + updateDoc.GetDocFlag("name").Description, + ) + injectLabelFlags(&updateDoc.Command, true) + + deleteDoc := man.Docs.GetCommand("policy/registered-resources/delete", + man.WithRun(policyDeleteRegisteredResource), + ) + deleteDoc.Flags().StringP( + deleteDoc.GetDocFlag("id").Name, + deleteDoc.GetDocFlag("id").Shorthand, + deleteDoc.GetDocFlag("id").Default, + deleteDoc.GetDocFlag("id").Description, + ) + deleteDoc.Flags().Bool( + deleteDoc.GetDocFlag("force").Name, + false, + deleteDoc.GetDocFlag("force").Description, + ) + + // Registered Resource Values commands + + getValueDoc := man.Docs.GetCommand("policy/registered-resources/values/get", + man.WithRun(policyGetRegisteredResourceValue), + ) + getValueDoc.Flags().StringP( + getValueDoc.GetDocFlag("id").Name, + getValueDoc.GetDocFlag("id").Shorthand, + getValueDoc.GetDocFlag("id").Default, + getValueDoc.GetDocFlag("id").Description, + ) + getValueDoc.Flags().StringP( + getValueDoc.GetDocFlag("fqn").Name, + getValueDoc.GetDocFlag("fqn").Shorthand, + getValueDoc.GetDocFlag("fqn").Default, + getValueDoc.GetDocFlag("fqn").Description, + ) + + listValuesDoc := man.Docs.GetCommand("policy/registered-resources/values/list", + man.WithRun(policyListRegisteredResourceValues), + ) + listValuesDoc.Flags().StringP( + listValuesDoc.GetDocFlag("resource").Name, + listValuesDoc.GetDocFlag("resource").Shorthand, + listValuesDoc.GetDocFlag("resource").Default, + listValuesDoc.GetDocFlag("resource").Description, + ) + listValuesDoc.Flags().StringP( + listValuesDoc.GetDocFlag("namespace").Name, + listValuesDoc.GetDocFlag("namespace").Shorthand, + listValuesDoc.GetDocFlag("namespace").Default, + listValuesDoc.GetDocFlag("namespace").Description, + ) + injectListPaginationFlags(listValuesDoc) + + createValueDoc := man.Docs.GetCommand("policy/registered-resources/values/create", + man.WithRun(policyCreateRegisteredResourceValue), + ) + createValueDoc.Flags().StringP( + createValueDoc.GetDocFlag("resource").Name, + createValueDoc.GetDocFlag("resource").Shorthand, + createValueDoc.GetDocFlag("resource").Default, + createValueDoc.GetDocFlag("resource").Description, + ) + createValueDoc.Flags().StringP( + createValueDoc.GetDocFlag("value").Name, + createValueDoc.GetDocFlag("value").Shorthand, + createValueDoc.GetDocFlag("value").Default, + createValueDoc.GetDocFlag("value").Description, + ) + createValueDoc.Flags().StringP( + createValueDoc.GetDocFlag("namespace").Name, + createValueDoc.GetDocFlag("namespace").Shorthand, + createValueDoc.GetDocFlag("namespace").Default, + createValueDoc.GetDocFlag("namespace").Description, + ) + createValueDoc.Flags().StringSliceVarP( + &actionAttributeValues, + createValueDoc.GetDocFlag("action-attribute-value").Name, + createValueDoc.GetDocFlag("action-attribute-value").Shorthand, + []string{}, + createValueDoc.GetDocFlag("action-attribute-value").Description, + ) + injectLabelFlags(&createValueDoc.Command, false) + + updateValueDoc := man.Docs.GetCommand("policy/registered-resources/values/update", + man.WithRun(policyUpdateRegisteredResourceValue), + ) + updateValueDoc.Flags().StringP( + updateDoc.GetDocFlag("id").Name, + updateDoc.GetDocFlag("id").Shorthand, + updateDoc.GetDocFlag("id").Default, + updateDoc.GetDocFlag("id").Description, + ) + updateValueDoc.Flags().StringP( + updateValueDoc.GetDocFlag("value").Name, + updateValueDoc.GetDocFlag("value").Shorthand, + updateValueDoc.GetDocFlag("value").Default, + updateValueDoc.GetDocFlag("value").Description, + ) + updateValueDoc.Flags().StringSliceVarP( + &actionAttributeValues, + updateValueDoc.GetDocFlag("action-attribute-value").Name, + updateValueDoc.GetDocFlag("action-attribute-value").Shorthand, + []string{}, + updateValueDoc.GetDocFlag("action-attribute-value").Description, + ) + injectLabelFlags(&updateValueDoc.Command, true) + updateValueDoc.Flags().Bool( + updateValueDoc.GetDocFlag("force").Name, + false, + updateValueDoc.GetDocFlag("force").Description, + ) + + deleteValueDoc := man.Docs.GetCommand("policy/registered-resources/values/delete", + man.WithRun(policyDeleteRegisteredResourceValue), + ) + deleteValueDoc.Flags().StringP( + deleteValueDoc.GetDocFlag("id").Name, + deleteValueDoc.GetDocFlag("id").Shorthand, + deleteValueDoc.GetDocFlag("id").Default, + deleteValueDoc.GetDocFlag("id").Description, + ) + deleteValueDoc.Flags().Bool( + deleteValueDoc.GetDocFlag("force").Name, + false, + deleteValueDoc.GetDocFlag("force").Description, + ) + + // Add commands to the policy command + + policyRegisteredResourcesDoc := man.Docs.GetCommand("policy/registered-resources", + man.WithSubcommands( + getDoc, + listDoc, + createDoc, + updateDoc, + deleteDoc, + ), + ) + + policyRegisteredResourceValuesDoc := man.Docs.GetCommand("policy/registered-resources/values", + man.WithSubcommands( + getValueDoc, + listValuesDoc, + createValueDoc, + updateValueDoc, + deleteValueDoc, + ), + ) + + policyRegisteredResourcesDoc.AddCommand(&policyRegisteredResourceValuesDoc.Command) + Cmd.AddCommand(&policyRegisteredResourcesDoc.Command) +} diff --git a/otdfctl/cmd/policy/resourceMappingGroups.go b/otdfctl/cmd/policy/resourceMappingGroups.go new file mode 100644 index 0000000000..1df8815da2 --- /dev/null +++ b/otdfctl/cmd/policy/resourceMappingGroups.go @@ -0,0 +1,224 @@ +package policy + +import ( + "fmt" + + "github.com/evertras/bubble-table/table" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/spf13/cobra" +) + +var policyResourceMappingGroupsCmd *cobra.Command + +func policyCreateResourceMappingGroup(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + nsID := c.Flags.GetRequiredID("namespace-id") + name := c.Flags.GetRequiredString("name") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + resourceMappingGroup, err := h.CreateResourceMappingGroup(cmd.Context(), nsID, name, getMetadataMutable(metadataLabels)) + if err != nil { + cli.ExitWithError("Failed to create resource mapping group", err) + } + rows := [][]string{ + {"Id", resourceMappingGroup.GetId()}, + {"Namespace Id", resourceMappingGroup.GetNamespaceId()}, + {"Group Name", resourceMappingGroup.GetName()}, + } + if mdRows := getMetadataRows(resourceMappingGroup.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, resourceMappingGroup.GetId(), t, resourceMappingGroup) +} + +func policyGetResourceMappingGroup(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + + resourceMappingGroup, err := h.GetResourceMappingGroup(cmd.Context(), id) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to get resource mapping group (%s)", id), err) + } + rows := [][]string{ + {"Id", resourceMappingGroup.GetId()}, + {"Namespace Id", resourceMappingGroup.GetNamespaceId()}, + {"Group Name", resourceMappingGroup.GetName()}, + } + if mdRows := getMetadataRows(resourceMappingGroup.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, resourceMappingGroup.GetId(), t, resourceMappingGroup) +} + +func policyListResourceMappingGroups(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + + resp, err := h.ListResourceMappingGroups(cmd.Context(), limit, offset) + if err != nil { + cli.ExitWithError("Failed to list resource mapping groups", err) + } + + t := cli.NewTable( + cli.NewUUIDColumn(), + table.NewFlexColumn("ns_id", "Namespace ID", cli.FlexColumnWidthFour), + table.NewFlexColumn("name", "Name", cli.FlexColumnWidthFour), + table.NewFlexColumn("labels", "Labels", cli.FlexColumnWidthOne), + table.NewFlexColumn("created_at", "Created At", cli.FlexColumnWidthOne), + table.NewFlexColumn("updated_at", "Updated At", cli.FlexColumnWidthOne), + ) + rows := []table.Row{} + for _, rmg := range resp.GetResourceMappingGroups() { + metadata := cli.ConstructMetadata(rmg.GetMetadata()) + rows = append(rows, table.NewRow(table.RowData{ + "id": rmg.GetId(), + "ns_id": rmg.GetNamespaceId(), + "name": rmg.GetName(), + "labels": metadata["Labels"], + "created_at": metadata["Created At"], + "updated_at": metadata["Updated At"], + })) + } + t = t.WithRows(rows) + t = cli.WithListPaginationFooter(t, resp.GetPagination()) + common.HandleSuccess(cmd, "", t, resp) +} + +func policyUpdateResourceMappingGroup(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + nsID := c.Flags.GetOptionalID("namespace-id") + name := c.Flags.GetOptionalString("name") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + resourceMappingGroup, err := h.UpdateResourceMappingGroup(cmd.Context(), id, nsID, name, getMetadataMutable(metadataLabels), getMetadataUpdateBehavior()) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to update resource mapping group (%s)", id), err) + } + rows := [][]string{ + {"Id", resourceMappingGroup.GetId()}, + {"Namespace Id", resourceMappingGroup.GetNamespaceId()}, + {"Group Name", resourceMappingGroup.GetName()}, + } + if mdRows := getMetadataRows(resourceMappingGroup.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, resourceMappingGroup.GetId(), t, resourceMappingGroup) +} + +func policyDeleteResourceMappingGroup(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + force := c.Flags.GetOptionalBool("force") + + cli.ConfirmAction(cli.ActionDelete, "resource-mapping-group", id, force) + + resourceMappingGroup, err := h.GetResourceMappingGroup(cmd.Context(), id) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to get resource mapping group for delete (%s)", id), err) + } + + _, err = h.DeleteResourceMappingGroup(cmd.Context(), id) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to delete resource mapping group (%s)", id), err) + } + rows := [][]string{ + {"Id", resourceMappingGroup.GetId()}, + {"Namespace Id", resourceMappingGroup.GetNamespaceId()}, + {"Group Name", resourceMappingGroup.GetName()}, + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, resourceMappingGroup.GetId(), t, resourceMappingGroup) +} + +func initResourceMappingGroupsCommands() { + createDoc := man.Docs.GetCommand("policy/resource-mapping-groups/create", + man.WithRun(policyCreateResourceMappingGroup), + ) + createDoc.Flags().String( + createDoc.GetDocFlag("namespace-id").Name, + createDoc.GetDocFlag("namespace-id").Default, + createDoc.GetDocFlag("namespace-id").Description, + ) + createDoc.Flags().String( + createDoc.GetDocFlag("name").Name, + createDoc.GetDocFlag("name").Default, + createDoc.GetDocFlag("name").Description, + ) + injectLabelFlags(&createDoc.Command, false) + + getDoc := man.Docs.GetCommand("policy/resource-mapping-groups/get", + man.WithRun(policyGetResourceMappingGroup), + ) + getDoc.Flags().String( + getDoc.GetDocFlag("id").Name, + getDoc.GetDocFlag("id").Default, + getDoc.GetDocFlag("id").Description, + ) + + listDoc := man.Docs.GetCommand("policy/resource-mapping-groups/list", + man.WithRun(policyListResourceMappingGroups), + ) + injectListPaginationFlags(listDoc) + + updateDoc := man.Docs.GetCommand("policy/resource-mapping-groups/update", + man.WithRun(policyUpdateResourceMappingGroup), + ) + updateDoc.Flags().String( + updateDoc.GetDocFlag("id").Name, + updateDoc.GetDocFlag("id").Default, + updateDoc.GetDocFlag("id").Description, + ) + updateDoc.Flags().String( + updateDoc.GetDocFlag("namespace-id").Name, + updateDoc.GetDocFlag("namespace-id").Default, + updateDoc.GetDocFlag("namespace-id").Description, + ) + updateDoc.Flags().String( + updateDoc.GetDocFlag("name").Name, + updateDoc.GetDocFlag("name").Default, + updateDoc.GetDocFlag("name").Description, + ) + injectLabelFlags(&updateDoc.Command, true) + + deleteDoc := man.Docs.GetCommand("policy/resource-mapping-groups/delete", + man.WithRun(policyDeleteResourceMappingGroup), + ) + deleteDoc.Flags().String( + deleteDoc.GetDocFlag("id").Name, + deleteDoc.GetDocFlag("id").Default, + deleteDoc.GetDocFlag("id").Description, + ) + deleteDoc.Flags().Bool( + deleteDoc.GetDocFlag("force").Name, + false, + deleteDoc.GetDocFlag("force").Description, + ) + + doc := man.Docs.GetCommand("policy/resource-mapping-groups", + man.WithSubcommands(createDoc, getDoc, listDoc, updateDoc, deleteDoc), + ) + policyResourceMappingGroupsCmd = &doc.Command + Cmd.AddCommand(policyResourceMappingGroupsCmd) +} diff --git a/otdfctl/cmd/policy/resourceMappings.go b/otdfctl/cmd/policy/resourceMappings.go new file mode 100644 index 0000000000..57a0ba2fde --- /dev/null +++ b/otdfctl/cmd/policy/resourceMappings.go @@ -0,0 +1,263 @@ +package policy + +import ( + _ "embed" // required for go:embed directives + "fmt" + "strings" + + "github.com/evertras/bubble-table/table" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/spf13/cobra" +) + +var ( + terms []string + resourceMappingsCmd *cobra.Command +) + +func createResourceMapping(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + attrID := c.Flags.GetRequiredID("attribute-value-id") + grpID := c.Flags.GetOptionalID("group-id") + terms = c.Flags.GetStringSlice("terms", terms, cli.FlagsStringSliceOptions{ + Min: 1, + }) + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + resourceMapping, err := h.CreateResourceMapping(attrID, terms, grpID, getMetadataMutable(metadataLabels)) + if err != nil { + cli.ExitWithError("Failed to create resource mapping", err) + } + rows := [][]string{ + {"Id", resourceMapping.GetId()}, + {"Attribute Value Id", resourceMapping.GetAttributeValue().GetId()}, + {"Attribute Value", resourceMapping.GetAttributeValue().GetValue()}, + {"Terms", strings.Join(resourceMapping.GetTerms(), ", ")}, + {"Group Id", resourceMapping.GetGroup().GetId()}, + {"Group Name", resourceMapping.GetGroup().GetName()}, + } + if mdRows := getMetadataRows(resourceMapping.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, resourceMapping.GetId(), t, resourceMapping) +} + +func getResourceMapping(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + + resourceMapping, err := h.GetResourceMapping(id) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to get resource mapping (%s)", id), err) + } + rows := [][]string{ + {"Id", resourceMapping.GetId()}, + {"Attribute Value Id", resourceMapping.GetAttributeValue().GetId()}, + {"Attribute Value", resourceMapping.GetAttributeValue().GetValue()}, + {"Terms", strings.Join(resourceMapping.GetTerms(), ", ")}, + {"Group Id", resourceMapping.GetGroup().GetId()}, + {"Group Name", resourceMapping.GetGroup().GetName()}, + } + if mdRows := getMetadataRows(resourceMapping.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, resourceMapping.GetId(), t, resourceMapping) +} + +func listResourceMappings(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + + resp, err := h.ListResourceMappings(cmd.Context(), limit, offset) + if err != nil { + cli.ExitWithError("Failed to list resource mappings", err) + } + + t := cli.NewTable( + cli.NewUUIDColumn(), + table.NewFlexColumn("attr_value_id", "Attribute Value Id", cli.FlexColumnWidthFive), + table.NewFlexColumn("attr_value", "Attribute Value", cli.FlexColumnWidthTwo), + table.NewFlexColumn("terms", "Terms", cli.FlexColumnWidthFour), + table.NewFlexColumn("group_id", "Group Id", cli.FlexColumnWidthFive), + table.NewFlexColumn("group_name", "Group Name", cli.FlexColumnWidthTwo), + table.NewFlexColumn("labels", "Labels", cli.FlexColumnWidthOne), + table.NewFlexColumn("created_at", "Created At", cli.FlexColumnWidthOne), + table.NewFlexColumn("updated_at", "Updated At", cli.FlexColumnWidthOne), + ) + rows := []table.Row{} + for _, resourceMapping := range resp.GetResourceMappings() { + metadata := cli.ConstructMetadata(resourceMapping.GetMetadata()) + rows = append(rows, table.NewRow(table.RowData{ + "id": resourceMapping.GetId(), + "attr_value_id": resourceMapping.GetAttributeValue().GetId(), + "attr_value": resourceMapping.GetAttributeValue().GetValue(), + "group_id": resourceMapping.GetGroup().GetId(), + "group_name": resourceMapping.GetGroup().GetName(), + "terms": strings.Join(resourceMapping.GetTerms(), ", "), + "labels": metadata["Labels"], + "created_at": metadata["Created At"], + "updated_at": metadata["Updated At"], + })) + } + t = t.WithRows(rows) + t = cli.WithListPaginationFooter(t, resp.GetPagination()) + common.HandleSuccess(cmd, "", t, resp) +} + +func updateResourceMapping(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + attrValueID := c.Flags.GetOptionalID("attribute-value-id") + grpID := c.Flags.GetOptionalID("group-id") + terms = c.Flags.GetStringSlice("terms", terms, cli.FlagsStringSliceOptions{}) + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + resourceMapping, err := h.UpdateResourceMapping(id, attrValueID, grpID, terms, getMetadataMutable(metadataLabels), getMetadataUpdateBehavior()) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to update resource mapping (%s)", id), err) + } + rows := [][]string{ + {"Id", resourceMapping.GetId()}, + {"Attribute Value Id", resourceMapping.GetAttributeValue().GetId()}, + {"Attribute Value", resourceMapping.GetAttributeValue().GetValue()}, + {"Terms", strings.Join(resourceMapping.GetTerms(), ", ")}, + {"Group Id", resourceMapping.GetGroup().GetId()}, + {"Group Name", resourceMapping.GetGroup().GetName()}, + } + if mdRows := getMetadataRows(resourceMapping.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, resourceMapping.GetId(), t, resourceMapping) +} + +func deleteResourceMapping(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + force := c.Flags.GetOptionalBool("force") + + cli.ConfirmAction(cli.ActionDelete, "resource-mapping", id, force) + + resourceMapping, err := h.GetResourceMapping(id) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to get resource mapping for delete (%s)", id), err) + } + + _, err = h.DeleteResourceMapping(id) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to delete resource mapping (%s)", id), err) + } + rows := [][]string{ + {"Id", resourceMapping.GetId()}, + {"Attribute Value Id", resourceMapping.GetAttributeValue().GetId()}, + {"Attribute Value", resourceMapping.GetAttributeValue().GetValue()}, + {"Terms", strings.Join(resourceMapping.GetTerms(), ", ")}, + {"Group Id", resourceMapping.GetGroup().GetId()}, + {"Group Name", resourceMapping.GetGroup().GetName()}, + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, resourceMapping.GetId(), t, resourceMapping) +} + +func initResourceMappingsCommands() { + createDoc := man.Docs.GetCommand("policy/resource-mappings/create", + man.WithRun(createResourceMapping), + ) + createDoc.Flags().String( + createDoc.GetDocFlag("attribute-value-id").Name, + createDoc.GetDocFlag("attribute-value-id").Default, + createDoc.GetDocFlag("attribute-value-id").Description, + ) + createDoc.Flags().StringSliceVar( + &terms, + createDoc.GetDocFlag("terms").Name, + []string{}, + createDoc.GetDocFlag("terms").Description, + ) + createDoc.Flags().String( + createDoc.GetDocFlag("group-id").Name, + createDoc.GetDocFlag("group-id").Default, + createDoc.GetDocFlag("group-id").Description, + ) + injectLabelFlags(&createDoc.Command, false) + + getDoc := man.Docs.GetCommand("policy/resource-mappings/get", + man.WithRun(getResourceMapping), + ) + getDoc.Flags().String( + getDoc.GetDocFlag("id").Name, + getDoc.GetDocFlag("id").Default, + getDoc.GetDocFlag("id").Description, + ) + + listDoc := man.Docs.GetCommand("policy/resource-mappings/list", + man.WithRun(listResourceMappings), + ) + injectListPaginationFlags(listDoc) + + updateDoc := man.Docs.GetCommand("policy/resource-mappings/update", + man.WithRun(updateResourceMapping), + ) + updateDoc.Flags().String( + updateDoc.GetDocFlag("id").Name, + updateDoc.GetDocFlag("id").Default, + updateDoc.GetDocFlag("id").Description, + ) + updateDoc.Flags().String( + updateDoc.GetDocFlag("attribute-value-id").Name, + updateDoc.GetDocFlag("attribute-value-id").Default, + updateDoc.GetDocFlag("attribute-value-id").Description, + ) + updateDoc.Flags().StringSliceVar( + &terms, + updateDoc.GetDocFlag("terms").Name, + []string{}, + updateDoc.GetDocFlag("terms").Description, + ) + updateDoc.Flags().String( + updateDoc.GetDocFlag("group-id").Name, + updateDoc.GetDocFlag("group-id").Default, + updateDoc.GetDocFlag("group-id").Description, + ) + injectLabelFlags(&updateDoc.Command, true) + + deleteDoc := man.Docs.GetCommand("policy/resource-mappings/delete", + man.WithRun(deleteResourceMapping), + ) + deleteDoc.Flags().String( + deleteDoc.GetDocFlag("id").Name, + deleteDoc.GetDocFlag("id").Default, + deleteDoc.GetDocFlag("id").Description, + ) + deleteDoc.Flags().Bool( + deleteDoc.GetDocFlag("force").Name, + false, + deleteDoc.GetDocFlag("force").Description, + ) + + doc := man.Docs.GetCommand("policy/resource-mappings", + man.WithSubcommands(createDoc, getDoc, listDoc, updateDoc, deleteDoc), + ) + resourceMappingsCmd = &doc.Command + Cmd.AddCommand(resourceMappingsCmd) +} diff --git a/otdfctl/cmd/policy/subjectConditionSets.go b/otdfctl/cmd/policy/subjectConditionSets.go new file mode 100644 index 0000000000..d117ce7e72 --- /dev/null +++ b/otdfctl/cmd/policy/subjectConditionSets.go @@ -0,0 +1,431 @@ +package policy + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/evertras/bubble-table/table" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/spf13/cobra" + "google.golang.org/protobuf/encoding/protojson" +) + +// Helper to unmarshal SubjectSets from JSON (stored as JSONB in the database column) +func unmarshalSubjectSetsProto(conditionJSON []byte) ([]*policy.SubjectSet, error) { + var ( + raw []json.RawMessage + ss []*policy.SubjectSet + ) + if err := json.Unmarshal(conditionJSON, &raw); err != nil { + return nil, err + } + + for _, r := range raw { + s := policy.SubjectSet{} + if err := protojson.Unmarshal(r, &s); err != nil { + return nil, err + } + ss = append(ss, &s) + } + + return ss, nil +} + +// Helper to marshal SubjectSets into JSON (stored as JSONB in the database column) +func marshalSubjectSetsProto(subjectSet []*policy.SubjectSet) ([]byte, error) { + var raw []json.RawMessage + for _, ss := range subjectSet { + b, err := protojson.Marshal(ss) + if err != nil { + return nil, err + } + raw = append(raw, b) + } + return json.Marshal(raw) +} + +func createSubjectConditionSet(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + var ssBytes []byte + + ssFlagJSON := c.Flags.GetOptionalString("subject-sets") + ssFileJSON := c.Flags.GetOptionalString("subject-sets-file-json") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + namespace := c.Flags.GetOptionalString("namespace") + + // validate no flag conflicts + if ssFileJSON == "" && ssFlagJSON == "" { + cli.ExitWithError("At least one subject set must be provided ('--subject-sets', '--subject-sets-file-json')", nil) + } else if ssFileJSON != "" && ssFlagJSON != "" { + cli.ExitWithError("Only one of '--subject-sets' or '--subject-sets-file-json' can be provided", nil) + } + + // read subject sets into bytes from either the flagged json file or json string + if ssFileJSON != "" { + jsonFile, err := os.Open(ssFileJSON) + if err != nil { + cli.ExitWithError("Failed to open file at path: "+ssFileJSON, err) + } + defer jsonFile.Close() + + bytes, err := io.ReadAll(jsonFile) + if err != nil { + cli.ExitWithError("Failed to read bytes from file at path: "+ssFileJSON, err) + } + ssBytes = bytes + } else { + ssBytes = []byte(ssFlagJSON) + } + + ss, err := unmarshalSubjectSetsProto(ssBytes) + if err != nil { + cli.ExitWithError("Error unmarshalling subject sets", err) + } + + scs, err := h.CreateSubjectConditionSet(cmd.Context(), ss, getMetadataMutable(metadataLabels), namespace) + if err != nil { + cli.ExitWithError("Error creating subject condition set", err) + } + + subjectSetsJSON, err := marshalSubjectSetsProto(scs.GetSubjectSets()) + if err != nil { + cli.ExitWithError("Error marshalling subject condition set", err) + } + + rows := [][]string{ + {"Id", scs.GetId()}, + {"Namespace", scs.GetNamespace().GetFqn()}, + {"SubjectSets", string(subjectSetsJSON)}, + } + + if mdRows := getMetadataRows(scs.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, scs.GetId(), t, scs) +} + +func getSubjectConditionSet(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + + scs, err := h.GetSubjectConditionSet(cmd.Context(), id) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Subject Condition Set with id %s not found", id), err) + } + subjectSetsJSON, err := marshalSubjectSetsProto(scs.GetSubjectSets()) + if err != nil { + cli.ExitWithError("Error marshalling subject condition set", err) + } + + rows := [][]string{ + {"Id", scs.GetId()}, + {"Namespace", scs.GetNamespace().GetFqn()}, + {"SubjectSets", string(subjectSetsJSON)}, + } + if mdRows := getMetadataRows(scs.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, scs.GetId(), t, scs) +} + +func listSubjectConditionSets(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + namespace := c.Flags.GetOptionalString("namespace") + + resp, err := h.ListSubjectConditionSets(cmd.Context(), limit, offset, namespace) + if err != nil { + cli.ExitWithError("Error listing subject condition sets", err) + } + + t := cli.NewTable( + cli.NewUUIDColumn(), + table.NewFlexColumn("namespace", "Namespace", cli.FlexColumnWidthFour), + table.NewFlexColumn("subject_sets", "SubjectSets", cli.FlexColumnWidthFour), + table.NewFlexColumn("labels", "Labels", cli.FlexColumnWidthOne), + table.NewFlexColumn("created_at", "Created At", cli.FlexColumnWidthOne), + table.NewFlexColumn("updated_at", "Updated At", cli.FlexColumnWidthOne), + ) + rows := []table.Row{} + for _, scs := range resp.GetSubjectConditionSets() { + subjectSetsJSON, err := marshalSubjectSetsProto(scs.GetSubjectSets()) + if err != nil { + cli.ExitWithError("Error marshalling subject condition set", err) + } + metadata := cli.ConstructMetadata(scs.GetMetadata()) + rows = append(rows, table.NewRow(table.RowData{ + "id": scs.GetId(), + "namespace": scs.GetNamespace().GetFqn(), + "subject_sets": string(subjectSetsJSON), + "labels": metadata["Labels"], + "created_at": metadata["Created At"], + "updated_at": metadata["Updated At"], + })) + } + t = t.WithRows(rows) + t = cli.WithListPaginationFooter(t, resp.GetPagination()) + common.HandleSuccess(cmd, "", t, resp) +} + +func updateSubjectConditionSet(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + id := c.Flags.GetRequiredID("id") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + ssFlagJSON := c.Flags.GetOptionalString("subject-sets") + ssFileJSON := c.Flags.GetOptionalString("subject-sets-file-json") + + var ssBytes []byte + // validate no flag conflicts + if ssFileJSON == "" && ssFlagJSON == "" { + cli.ExitWithError("At least one subject set must be provided ('--subject-sets', '--subject-sets-file-json')", nil) + } else if ssFileJSON != "" && ssFlagJSON != "" { + cli.ExitWithError("Only one of '--subject-sets' or '--subject-sets-file-json' can be provided", nil) + } + + // read subject sets into bytes from either the flagged json file or json string + if ssFileJSON != "" { + jsonFile, err := os.Open(ssFileJSON) + if err != nil { + cli.ExitWithError("Failed to open file at path: "+ssFileJSON, err) + } + defer jsonFile.Close() + + bytes, err := io.ReadAll(jsonFile) + if err != nil { + cli.ExitWithError("Failed to read bytes from file at path: "+ssFileJSON, err) + } + ssBytes = bytes + } else { + ssBytes = []byte(ssFlagJSON) + } + + ss, err := unmarshalSubjectSetsProto(ssBytes) + if err != nil { + cli.ExitWithError("Error unmarshalling subject sets", err) + } + + _, err = h.UpdateSubjectConditionSet(ctx, id, ss, getMetadataMutable(metadataLabels), getMetadataUpdateBehavior()) + if err != nil { + cli.ExitWithError("Error updating subject condition set", err) + } + + scs, err := h.GetSubjectConditionSet(ctx, id) + if err != nil { + cli.ExitWithError("Error getting subject condition set", err) + } + + subjectSetsJSON, err := marshalSubjectSetsProto(scs.GetSubjectSets()) + if err != nil { + cli.ExitWithError("Error marshalling subject condition set", err) + } + + rows := [][]string{ + {"Id", scs.GetId()}, + {"SubjectSets", string(subjectSetsJSON)}, + } + + if mdRows := getMetadataRows(scs.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, scs.GetId(), t, scs) +} + +func deleteSubjectConditionSet(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + ctx := cmd.Context() + id := c.Flags.GetRequiredID("id") + force := c.Flags.GetOptionalBool("force") + + scs, err := h.GetSubjectConditionSet(ctx, id) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Subject Condition Set with id %s not found", id), err) + } + + cli.ConfirmAction(cli.ActionDelete, "Subject Condition Sets", "all unmapped", force) + + if err := h.DeleteSubjectConditionSet(ctx, id); err != nil { + cli.ExitWithError(fmt.Sprintf("Subject Condition Set with id %s not found", id), err) + } + + subjectSetsJSON, err := marshalSubjectSetsProto(scs.GetSubjectSets()) + if err != nil { + cli.ExitWithError("Error marshalling subject condition set", err) + } + + rows := [][]string{ + {"Id", scs.GetId()}, + {"SubjectSets", string(subjectSetsJSON)}, + } + + if mdRows := getMetadataRows(scs.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, scs.GetId(), t, scs) +} + +func pruneSubjectConditionSet(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + force := c.Flags.GetOptionalBool("force") + + cli.ConfirmAction(cli.ActionDelete, "all unmapped Subject Condition Sets", "", force) + + pruned, err := h.PruneSubjectConditionSets(cmd.Context()) + if err != nil { + cli.ExitWithError("Failed to prune unmapped Subject Condition Sets", err) + } + + rows := []table.Row{} + for _, scs := range pruned { + rows = append(rows, table.NewRow(table.RowData{ + "id": scs.GetId(), + })) + } + + t := cli.NewTable( + cli.NewUUIDColumn(), + ) + t = t.WithRows(rows) + common.HandleSuccess(cmd, "", t, pruned) +} + +var subjectConditionSetsCmd *cobra.Command + +func initSubjectConditionSetsCommands() { + createDoc := man.Docs.GetCommand("policy/subject-condition-sets/create", + man.WithRun(createSubjectConditionSet), + ) + injectLabelFlags(&createDoc.Command, false) + createDoc.Flags().StringP( + createDoc.GetDocFlag("subject-sets").Name, + createDoc.GetDocFlag("subject-sets").Shorthand, + createDoc.GetDocFlag("subject-sets").Default, + createDoc.GetDocFlag("subject-sets").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("subject-sets-file-json").Name, + createDoc.GetDocFlag("subject-sets-file-json").Shorthand, + createDoc.GetDocFlag("subject-sets-file-json").Default, + createDoc.GetDocFlag("subject-sets-file-json").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("namespace").Name, + createDoc.GetDocFlag("namespace").Shorthand, + createDoc.GetDocFlag("namespace").Default, + createDoc.GetDocFlag("namespace").Description, + ) + + getDoc := man.Docs.GetCommand("policy/subject-condition-sets/get", + man.WithRun(getSubjectConditionSet), + ) + getDoc.Flags().StringP( + getDoc.GetDocFlag("id").Name, + getDoc.GetDocFlag("id").Shorthand, + getDoc.GetDocFlag("id").Default, + getDoc.GetDocFlag("id").Description, + ) + + listDoc := man.Docs.GetCommand("policy/subject-condition-sets/list", + man.WithRun(listSubjectConditionSets), + ) + injectListPaginationFlags(listDoc) + listDoc.Flags().StringP( + listDoc.GetDocFlag("namespace").Name, + listDoc.GetDocFlag("namespace").Shorthand, + listDoc.GetDocFlag("namespace").Default, + listDoc.GetDocFlag("namespace").Description, + ) + + updateDoc := man.Docs.GetCommand("policy/subject-condition-sets/update", + man.WithRun(updateSubjectConditionSet), + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("id").Name, + updateDoc.GetDocFlag("id").Shorthand, + updateDoc.GetDocFlag("id").Default, + updateDoc.GetDocFlag("id").Description, + ) + injectLabelFlags(&updateDoc.Command, true) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("subject-sets").Name, + updateDoc.GetDocFlag("subject-sets").Shorthand, + updateDoc.GetDocFlag("subject-sets").Default, + updateDoc.GetDocFlag("subject-sets").Description, + ) + updateDoc.Flags().StringP( + createDoc.GetDocFlag("subject-sets-file-json").Name, + createDoc.GetDocFlag("subject-sets-file-json").Shorthand, + createDoc.GetDocFlag("subject-sets-file-json").Default, + createDoc.GetDocFlag("subject-sets-file-json").Description, + ) + + deleteDoc := man.Docs.GetCommand( + "policy/subject-condition-sets/delete", + man.WithRun(deleteSubjectConditionSet), + ) + deleteDoc.Flags().StringP( + deleteDoc.GetDocFlag("id").Name, + deleteDoc.GetDocFlag("id").Shorthand, + deleteDoc.GetDocFlag("id").Default, + deleteDoc.GetDocFlag("id").Description, + ) + deleteDoc.Flags().Bool( + deleteDoc.GetDocFlag("force").Name, + false, + deleteDoc.GetDocFlag("force").Description, + ) + + pruneDoc := man.Docs.GetCommand( + "policy/subject-condition-sets/prune", + man.WithRun(pruneSubjectConditionSet), + ) + pruneDoc.Flags().Bool( + pruneDoc.GetDocFlag("force").Name, + false, + pruneDoc.GetDocFlag("force").Description, + ) + + doc := man.Docs.GetCommand("policy/subject-condition-sets", + man.WithSubcommands( + createDoc, + getDoc, + listDoc, + updateDoc, + deleteDoc, + pruneDoc, + ), + ) + subjectConditionSetsCmd = &doc.Command + Cmd.AddCommand(subjectConditionSetsCmd) +} diff --git a/otdfctl/cmd/policy/subjectMappings.go b/otdfctl/cmd/policy/subjectMappings.go new file mode 100644 index 0000000000..55c133b1d2 --- /dev/null +++ b/otdfctl/cmd/policy/subjectMappings.go @@ -0,0 +1,482 @@ +package policy + +import ( + "encoding/json" + "fmt" + + "github.com/evertras/bubble-table/table" + "github.com/google/uuid" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/handlers" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/subjectmapping" + "github.com/spf13/cobra" +) + +var ( + actionFlagValues []string + selectors []string +) + +func policyGetSubjectMapping(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + + mapping, err := h.GetSubjectMapping(cmd.Context(), id) + if err != nil { + errMsg := fmt.Sprintf("Failed to find subject mapping (%s)", id) + cli.ExitWithError(errMsg, err) + } + var actionsJSON []byte + if actionsJSON, err = json.Marshal(mapping.GetActions()); err != nil { + cli.ExitWithError("Error marshalling subject mapping actions", err) + } + + var subjectSetsJSON []byte + if subjectSetsJSON, err = json.Marshal(mapping.GetSubjectConditionSet().GetSubjectSets()); err != nil { + cli.ExitWithError("Error marshalling subject condition set", err) + } + + rows := [][]string{ + {"Id", mapping.GetId()}, + {"Namespace", mapping.GetNamespace().GetFqn()}, + {"Attribute Value: Id", mapping.GetAttributeValue().GetId()}, + {"Attribute Value: Value", mapping.GetAttributeValue().GetValue()}, + {"Actions", string(actionsJSON)}, + {"Subject Condition Set: Id", mapping.GetSubjectConditionSet().GetId()}, + {"Subject Condition Set", string(subjectSetsJSON)}, + } + if mdRows := getMetadataRows(mapping.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, mapping.GetId(), t, mapping) +} + +func policyListSubjectMappings(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + limit := c.Flags.GetRequiredInt32("limit") + offset := c.Flags.GetRequiredInt32("offset") + namespace := c.Flags.GetOptionalString("namespace") + + resp, err := h.ListSubjectMappings(cmd.Context(), limit, offset, namespace) + if err != nil { + cli.ExitWithError("Failed to get subject mappings", err) + } + t := cli.NewTable( + cli.NewUUIDColumn(), + table.NewFlexColumn("namespace", "Namespace", cli.FlexColumnWidthFour), + table.NewFlexColumn("value_id", "Attribute Value Id", cli.FlexColumnWidthFour), + table.NewFlexColumn("value_fqn", "Attibribute Value FQN", cli.FlexColumnWidthFour), + table.NewFlexColumn("actions", "Actions", cli.FlexColumnWidthTwo), + table.NewFlexColumn("subject_condition_set_id", "Subject Condition Set: Id", cli.FlexColumnWidthFour), + table.NewFlexColumn("subject_condition_set", "Subject Condition Set", cli.FlexColumnWidthThree), + ) + rows := []table.Row{} + for _, sm := range resp.GetSubjectMappings() { + var actionsJSON []byte + if actionsJSON, err = json.Marshal(sm.GetActions()); err != nil { + cli.ExitWithError("Error marshalling subject mapping actions", err) + } + + var subjectSetsJSON []byte + if subjectSetsJSON, err = json.Marshal(sm.GetSubjectConditionSet().GetSubjectSets()); err != nil { + cli.ExitWithError("Error marshalling subject condition set", err) + } + + rows = append(rows, table.NewRow(table.RowData{ + "id": sm.GetId(), + "namespace": sm.GetNamespace().GetFqn(), + "value_id": sm.GetAttributeValue().GetId(), + "value_fqn": sm.GetAttributeValue().GetFqn(), + "actions": string(actionsJSON), + "subject_condition_set_id": sm.GetSubjectConditionSet().GetId(), + "subject_condition_set": string(subjectSetsJSON), + })) + } + t = t.WithRows(rows) + t = cli.WithListPaginationFooter(t, resp.GetPagination()) + common.HandleSuccess(cmd, "", t, resp) +} + +func policyCreateSubjectMapping(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + attrValueID := c.Flags.GetRequiredID("attribute-value-id") + actionFlagValues = c.Flags.GetStringSlice("action", actionFlagValues, cli.FlagsStringSliceOptions{Min: 0}) + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + existingSCSId := c.Flags.GetOptionalID("subject-condition-set-id") + // NOTE: labels within a new Subject Condition Set created on a SM creation are not supported + newScsJSON := c.Flags.GetOptionalString("subject-condition-set-new") + namespace := c.Flags.GetOptionalString("namespace") + + // validations + if len(actionFlagValues) == 0 { + cli.ExitWithError("At least one Action [--action] is required", nil) + } + if existingSCSId == "" && newScsJSON == "" { + cli.ExitWithError("At least one Subject Condition Set flag [--subject-condition-set-id, --subject-condition-set-new] must be provided", nil) + } + + actions := make([]*policy.Action, len(actionFlagValues)) + for i, a := range actionFlagValues { + action := &policy.Action{} + _, err := uuid.Parse(a) + if err != nil { + action.Name = a + } else { + action.Id = a + } + actions[i] = action + } + + var scs *subjectmapping.SubjectConditionSetCreate + if newScsJSON != "" { + ss, err := unmarshalSubjectSetsProto([]byte(newScsJSON)) + if err != nil { + cli.ExitWithError("Error unmarshalling subject sets", err) + } + scs = &subjectmapping.SubjectConditionSetCreate{ + SubjectSets: ss, + } + } + + mapping, err := h.CreateNewSubjectMapping(cmd.Context(), attrValueID, actions, existingSCSId, scs, getMetadataMutable(metadataLabels), namespace) + if err != nil { + cli.ExitWithError("Failed to create subject mapping", err) + } + + var actionsJSON []byte + if actionsJSON, err = json.Marshal(mapping.GetActions()); err != nil { + cli.ExitWithError("Error marshalling subject mapping actions", err) + } + + var subjectSetsJSON []byte + if mapping.GetSubjectConditionSet() != nil { + if subjectSetsJSON, err = json.Marshal(mapping.GetSubjectConditionSet().GetSubjectSets()); err != nil { + cli.ExitWithError("Error marshalling subject condition set", err) + } + } + + rows := [][]string{ + {"Id", mapping.GetId()}, + {"Namespace", mapping.GetNamespace().GetFqn()}, + {"Attribute Value Id", mapping.GetAttributeValue().GetId()}, + {"Actions", string(actionsJSON)}, + {"Subject Condition Set: Id", mapping.GetSubjectConditionSet().GetId()}, + {"Subject Condition Set", string(subjectSetsJSON)}, + } + + if mdRows := getMetadataRows(mapping.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, mapping.GetId(), t, mapping) +} + +func policyDeleteSubjectMapping(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + force := c.Flags.GetOptionalBool("force") + + sm, err := h.GetSubjectMapping(cmd.Context(), id) + if err != nil { + errMsg := fmt.Sprintf("Failed to find subject mapping (%s)", id) + cli.ExitWithError(errMsg, err) + } + + cli.ConfirmAction(cli.ActionDelete, "subject mapping", sm.GetId(), force) + + deleted, err := h.DeleteSubjectMapping(cmd.Context(), id) + if err != nil { + errMsg := fmt.Sprintf("Failed to delete subject mapping (%s)", id) + cli.ExitWithError(errMsg, err) + } + rows := [][]string{{"Id", sm.GetId()}} + if mdRows := getMetadataRows(deleted.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + common.HandleSuccess(cmd, id, t, deleted) +} + +func policyUpdateSubjectMapping(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + id := c.Flags.GetRequiredID("id") + actionFlagValues = c.Flags.GetStringSlice("action", actionFlagValues, cli.FlagsStringSliceOptions{Min: 0}) + scsID := c.Flags.GetOptionalID("subject-condition-set-id") + metadataLabels = c.Flags.GetStringSlice("label", metadataLabels, cli.FlagsStringSliceOptions{Min: 0}) + + var actions []*policy.Action + if len(actionFlagValues) > 0 { + for _, a := range actionFlagValues { + action := &policy.Action{} + _, err := uuid.Parse(a) + if err != nil { + action.Name = a + } else { + action.Id = a + } + actions = append(actions, action) + } + } + + updated, err := h.UpdateSubjectMapping( + cmd.Context(), + id, + scsID, + actions, + getMetadataMutable(metadataLabels), + getMetadataUpdateBehavior(), + ) + if err != nil { + cli.ExitWithError("Failed to update subject mapping", err) + } + rows := [][]string{ + {"Id", id}, + } + if mdRows := getMetadataRows(updated.GetMetadata()); mdRows != nil { + rows = append(rows, mdRows...) + } + t := cli.NewTabular(rows...) + + common.HandleSuccess(cmd, id, t, updated) +} + +func policyMatchSubjectMappings(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + h := common.NewHandler(c) + defer h.Close() + + subject := c.Flags.GetOptionalString("subject") + selectors = c.Flags.GetStringSlice("selector", selectors, cli.FlagsStringSliceOptions{Min: 0}) + + if len(selectors) > 0 && subject != "" { + cli.ExitWithError("Must provide either '--subject' or '--selector' flag values, not both", nil) + } + + if subject != "" { + flattened, err := handlers.FlattenSubjectContext(subject) + if err != nil { + cli.ExitWithError("Could not process '--subject' value", err) + } + for _, item := range flattened { + selectors = append(selectors, item.Key) + } + } + + matched, err := h.MatchSubjectMappings(cmd.Context(), selectors) + if err != nil { + cli.ExitWithError(fmt.Sprintf("Failed to match subject mappings with selectors %v", selectors), err) + } + + t := cli.NewTable( + cli.NewUUIDColumn(), + table.NewFlexColumn("subject_attrval_id", "Subject AttrVal: Id", cli.FlexColumnWidthFour), + table.NewFlexColumn("subject_attrval_value", "Subject AttrVal: Value", cli.FlexColumnWidthThree), + table.NewFlexColumn("actions", "Actions", cli.FlexColumnWidthTwo), + table.NewFlexColumn("subject_condition_set_id", "Subject Condition Set: Id", cli.FlexColumnWidthFour), + table.NewFlexColumn("subject_condition_set", "Subject Condition Set", cli.FlexColumnWidthThree), + ) + rows := []table.Row{} + for _, sm := range matched { + var actionsJSON []byte + if actionsJSON, err = json.Marshal(sm.GetActions()); err != nil { + cli.ExitWithError("Error marshalling subject mapping actions", err) + } + + var subjectSetsJSON []byte + if subjectSetsJSON, err = json.Marshal(sm.GetSubjectConditionSet().GetSubjectSets()); err != nil { + cli.ExitWithError("Error marshalling subject condition set", err) + } + metadata := cli.ConstructMetadata(sm.GetMetadata()) + + rows = append(rows, table.NewRow(table.RowData{ + "id": sm.GetId(), + "subject_attrval_id": sm.GetAttributeValue().GetId(), + "subject_attrval_value": sm.GetAttributeValue().GetValue(), + "actions": string(actionsJSON), + "subject_condition_set_id": sm.GetSubjectConditionSet().GetId(), + "subject_condition_set": string(subjectSetsJSON), + "labels": metadata["Labels"], + "created_at": metadata["Created At"], + "updated_at": metadata["Updated At"], + })) + } + t = t.WithRows(rows) + common.HandleSuccess(cmd, "", t, matched) +} + +func initSubjectMappingsCommands() { + getDoc := man.Docs.GetCommand("policy/subject-mappings/get", + man.WithRun(policyGetSubjectMapping), + ) + getDoc.Flags().StringP( + getDoc.GetDocFlag("id").Name, + getDoc.GetDocFlag("id").Shorthand, + getDoc.GetDocFlag("id").Default, + getDoc.GetDocFlag("id").Description, + ) + + listDoc := man.Docs.GetCommand("policy/subject-mappings/list", + man.WithRun(policyListSubjectMappings), + ) + injectListPaginationFlags(listDoc) + listDoc.Flags().StringP( + listDoc.GetDocFlag("namespace").Name, + listDoc.GetDocFlag("namespace").Shorthand, + listDoc.GetDocFlag("namespace").Default, + listDoc.GetDocFlag("namespace").Description, + ) + + createDoc := man.Docs.GetCommand("policy/subject-mappings/create", + man.WithRun(policyCreateSubjectMapping), + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("attribute-value-id").Name, + createDoc.GetDocFlag("attribute-value-id").Shorthand, + createDoc.GetDocFlag("attribute-value-id").Default, + createDoc.GetDocFlag("attribute-value-id").Description, + ) + // deprecated + createDoc.Flags().StringSliceVarP( + &[]string{}, + createDoc.GetDocFlag("action-standard").Name, + createDoc.GetDocFlag("action-standard").Shorthand, + []string{}, + createDoc.GetDocFlag("action-standard").Description, + ) + // deprecated + createDoc.Flags().StringSliceVarP( + &[]string{}, + createDoc.GetDocFlag("action-custom").Name, + createDoc.GetDocFlag("action-custom").Shorthand, + []string{}, + createDoc.GetDocFlag("action-custom").Description, + ) + createDoc.Flags().StringSliceVarP( + &actionFlagValues, + createDoc.GetDocFlag("action").Name, + createDoc.GetDocFlag("action").Shorthand, + []string{}, + createDoc.GetDocFlag("action").Description, + ) + createDoc.Flags().String( + createDoc.GetDocFlag("subject-condition-set-id").Name, + createDoc.GetDocFlag("subject-condition-set-id").Default, + createDoc.GetDocFlag("subject-condition-set-id").Description, + ) + createDoc.Flags().String( + createDoc.GetDocFlag("subject-condition-set-new").Name, + createDoc.GetDocFlag("subject-condition-set-new").Default, + createDoc.GetDocFlag("subject-condition-set-new").Description, + ) + createDoc.Flags().StringP( + createDoc.GetDocFlag("namespace").Name, + createDoc.GetDocFlag("namespace").Shorthand, + createDoc.GetDocFlag("namespace").Default, + createDoc.GetDocFlag("namespace").Description, + ) + injectLabelFlags(&createDoc.Command, false) + + updateDoc := man.Docs.GetCommand("policy/subject-mappings/update", + man.WithRun(policyUpdateSubjectMapping), + ) + updateDoc.Flags().StringP( + updateDoc.GetDocFlag("id").Name, + updateDoc.GetDocFlag("id").Shorthand, + updateDoc.GetDocFlag("id").Default, + updateDoc.GetDocFlag("id").Description, + ) + // deprecated + updateDoc.Flags().StringSliceVarP( + &[]string{}, + updateDoc.GetDocFlag("action-standard").Name, + updateDoc.GetDocFlag("action-standard").Shorthand, + []string{}, + updateDoc.GetDocFlag("action-standard").Description, + ) + updateDoc.Flags().StringSliceVarP( + &[]string{}, + updateDoc.GetDocFlag("action-custom").Name, + updateDoc.GetDocFlag("action-custom").Shorthand, + []string{}, + updateDoc.GetDocFlag("action-custom").Description, + ) + updateDoc.Flags().StringSliceVarP( + &actionFlagValues, + updateDoc.GetDocFlag("action").Name, + updateDoc.GetDocFlag("action").Shorthand, + []string{}, + updateDoc.GetDocFlag("action").Description, + ) + updateDoc.Flags().String( + updateDoc.GetDocFlag("subject-condition-set-id").Name, + updateDoc.GetDocFlag("subject-condition-set-id").Default, + updateDoc.GetDocFlag("subject-condition-set-id").Description, + ) + injectLabelFlags(&updateDoc.Command, true) + + deleteDoc := man.Docs.GetCommand("policy/subject-mappings/delete", + man.WithRun(policyDeleteSubjectMapping), + ) + deleteDoc.Flags().StringP( + deleteDoc.GetDocFlag("id").Name, + deleteDoc.GetDocFlag("id").Shorthand, + deleteDoc.GetDocFlag("id").Default, + deleteDoc.GetDocFlag("id").Description, + ) + deleteDoc.Flags().Bool( + deleteDoc.GetDocFlag("force").Name, + false, + deleteDoc.GetDocFlag("force").Description, + ) + + matchDoc := man.Docs.GetCommand("policy/subject-mappings/match", + man.WithRun(policyMatchSubjectMappings), + ) + matchDoc.Flags().StringP( + matchDoc.GetDocFlag("subject").Name, + matchDoc.GetDocFlag("subject").Shorthand, + matchDoc.GetDocFlag("subject").Default, + matchDoc.GetDocFlag("subject").Description, + ) + matchDoc.Flags().StringSliceVarP( + &selectors, + matchDoc.GetDocFlag("selector").Name, + matchDoc.GetDocFlag("selector").Shorthand, + []string{}, + matchDoc.GetDocFlag("selector").Description, + ) + + doc := man.Docs.GetCommand("policy/subject-mappings", + man.WithSubcommands( + createDoc, + getDoc, + listDoc, + updateDoc, + deleteDoc, + matchDoc, + ), + ) + subjectMappingCmd := &doc.Command + Cmd.AddCommand(subjectMappingCmd) +} diff --git a/otdfctl/cmd/profile.go b/otdfctl/cmd/profile.go new file mode 100644 index 0000000000..5ee92a3293 --- /dev/null +++ b/otdfctl/cmd/profile.go @@ -0,0 +1,343 @@ +package cmd + +import ( + "errors" + "fmt" + "runtime" + "strings" + + osprofiles "github.com/jrschumacher/go-osprofiles" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/config" + "github.com/opentdf/platform/otdfctl/pkg/profiles" + "github.com/opentdf/platform/otdfctl/pkg/utils" + "github.com/spf13/cobra" +) + +var ( + runningInLinux = runtime.GOOS == "linux" + runningInTestMode = config.TestMode == "true" +) + +const ( + profileMigrationLongDesc = "Migrate all profiles from keyring to filesystem. " + + "If you get stuck during your migration due to name collisions across the filesystem/keyring, please" + + " delete the specific profile from either the filesystem or keyring and run the migration again." + + " If that still doesn't work, you can remove all profiles from the filesystem via the `delete-all` command." +) + +func newProfilerFromCLI(c *cli.Cli) *osprofiles.Profiler { + driverType := getDriverTypeFromUser(c) + profiler, err := profiles.NewProfiler(string(driverType)) + if err != nil { + cli.ExitWithError("Error creating profiler", err) + } + + return profiler +} + +func getDriverTypeFromUser(c *cli.Cli) profiles.ProfileDriver { + driverTypeStr := string(profiles.ProfileDriverDefault) + store := c.FlagHelper.GetOptionalString("store") + if len(store) > 0 { + driverTypeStr = store + } + + driverType, err := profiles.ToProfileDriver(driverTypeStr) + if err != nil { + cli.ExitWithError("Error converting store type", err) + } + + return driverType +} + +var profileCmd = &cobra.Command{ + Use: "profile", + Aliases: []string{"profiles", "prof"}, + Short: "Manage profiles (experimental)", + Hidden: runningInLinux && !runningInTestMode, +} + +var profileCreateCmd = &cobra.Command{ + Use: "create ", + Aliases: []string{"add"}, + Short: "Create a new profile", + //nolint:mnd // two args + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + profileName := args[0] + endpoint := args[1] + + setDefault := c.FlagHelper.GetOptionalBool("set-default") + tlsNoVerify := c.FlagHelper.GetOptionalBool("tls-no-verify") + outputFormat := c.FlagHelper.GetOptionalString("output-format") + if !profiles.IsValidOutputFormat(outputFormat) { + c.ExitWithError("Output format must be either 'styled' or 'json'", nil) + } + + profileConfig := profiles.ProfileConfig{ + Name: profileName, + Endpoint: endpoint, + TLSNoVerify: tlsNoVerify, + OutputFormat: profiles.NormalizeOutputFormat(outputFormat), + } + _, err := profiles.NewOtdfctlProfileStore(profiles.ProfileDriverFileSystem, &profileConfig, setDefault) + if err != nil { + c.ExitWithError("Failed to create profile", err) + } + c.ExitWithSuccess(fmt.Sprintf("Profile %s created", profileName)) + }, +} + +var profileListCmd = &cobra.Command{ + Use: "list", + Short: "List profiles", + Run: func(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + driverType := getDriverTypeFromUser(c) + profiler := newProfilerFromCLI(c) + + globalCfg := osprofiles.GetGlobalConfig(profiler) + defaultProfile := globalCfg.GetDefaultProfile() + + var sb strings.Builder + fmt.Fprintf(&sb, "Listing profiles from %s\n", driverType) + + for _, p := range osprofiles.ListProfiles(profiler) { + if p == defaultProfile { + fmt.Fprintf(&sb, "* %s\n", p) + continue + } + fmt.Fprintf(&sb, " %s\n", p) + } + + c.ExitWithMessage(sb.String(), cli.ExitCodeSuccess) + }, +} + +var profileGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a profile value", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + profileName := args[0] + + driverType := getDriverTypeFromUser(c) + profileStore, err := profiles.LoadOtdfctlProfileStore(driverType, profileName) + if err != nil { + cli.ExitWithError("Error loading profile store for profile "+profileName, err) + } + + isDefault := "false" + if profileStore.IsDefault() { + isDefault = "true" + } + + var auth string + ac := profileStore.GetAuthCredentials() + if ac.AuthType == profiles.AuthTypeClientCredentials { + maskedSecret := "********" + auth = "client-credentials (" + ac.ClientID + ", " + maskedSecret + ")" + } + + t := cli.NewTabular( + []string{"Profile", profileStore.Name()}, + []string{"Endpoint", profileStore.GetEndpoint()}, + []string{"Is default", isDefault}, + []string{"Output format", profileStore.GetOutputFormat()}, + []string{"Auth type", auth}, + ) + + c.ExitWithMessage(t.View(), cli.ExitCodeSuccess) + }, +} + +var profileDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a profile", + Run: func(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + profileName := args[0] + + // TODO: suggest delete-all command to delete all profiles including default + driverType := getDriverTypeFromUser(c) + profiler := newProfilerFromCLI(c) + + if err := osprofiles.DeleteProfile[*profiles.ProfileConfig](profiler, profileName); err != nil { + if errors.Is(err, osprofiles.ErrCannotDeleteDefaultProfile) { + c.ExitWithWarning("Profile is set as default. Please set another profile as default before deleting.") + } + c.ExitWithError("Failed to delete profile", err) + } + c.ExitWithMessage(fmt.Sprintf("Deleted profile %s from %s", profileName, driverType), cli.ExitCodeSuccess) + }, +} + +var profileDeleteAllCmd = &cobra.Command{ + Use: "delete-all", + Short: "Delete all profiles", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + + force := c.Flags.GetOptionalBool("force") + driverType := getDriverTypeFromUser(c) + profiler := newProfilerFromCLI(c) + + profilesList := osprofiles.ListProfiles(profiler) + if len(profilesList) == 0 { + c.ExitWithMessage("No profiles found to delete", cli.ExitCodeSuccess) + return + } + + cli.ConfirmAction(cli.ActionDelete, fmt.Sprintf("all profiles from %s", driverType), config.AppName, force) + + if err := profiler.DeleteAllProfiles(); err != nil { + c.ExitWithError("Failed to delete all profiles", err) + } + c.ExitWithMessage(fmt.Sprintf("Deleted %d profiles from %s", len(profilesList), driverType), cli.ExitCodeSuccess) + }, +} + +var profileSetDefaultCmd = &cobra.Command{ + Use: "set-default ", + Short: "Set a profile as default", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + profileName := args[0] + profiler := newProfilerFromCLI(c) + + if err := osprofiles.SetDefaultProfile(profiler, profileName); err != nil { + c.ExitWithError("Failed to set default profile", err) + } + c.ExitWithMessage(fmt.Sprintf("Set profile %s as default", profileName), cli.ExitCodeSuccess) + }, +} + +var profileSetEndpointCmd = &cobra.Command{ + Use: "set-endpoint ", + Short: "Set a profile value", + //nolint:mnd // two args + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + profileName := args[0] + endpoint := args[1] + profiler := newProfilerFromCLI(c) + + store, err := osprofiles.GetProfile[*profiles.ProfileConfig](profiler, profileName) + if err != nil { + cli.ExitWithError("Failed to load profile", err) + } + + p, ok := store.Profile.(*profiles.ProfileConfig) + if !ok || p == nil { + cli.ExitWithError("Failed to load profile", errors.New("invalid profile configuration")) + } + + u, err := utils.NormalizeEndpoint(endpoint) + if err != nil { + c.ExitWithError("Failed to set endpoint", err) + } + + p.Endpoint = u.String() + if err := store.Save(); err != nil { + c.ExitWithError("Failed to set endpoint", err) + } + c.ExitWithMessage(fmt.Sprintf("Set endpoint %s for profile %s ", endpoint, profileName), cli.ExitCodeSuccess) + }, +} + +var profileSetOutputFormatCmd = &cobra.Command{ + Use: "set-output-format ", + Short: "Set the preferred output format for a profile", + Args: cobra.ExactArgs(2), //nolint:mnd // ignore argument as magic number, self-explanatory + Run: func(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + profileName := args[0] + format := args[1] + + if !profiles.IsValidOutputFormat(format) { + c.ExitWithError("Output format must be either 'styled' or 'json'", nil) + } + + store, err := profiles.LoadOtdfctlProfileStore(profiles.ProfileDriverFileSystem, profileName) + if err != nil { + cli.ExitWithError("Failed to load profile", err) + } + + if err := store.SetOutputFormat(format); err != nil { + c.ExitWithError("Failed to set output format", err) + } + c.ExitWithSuccess(fmt.Sprintf("Set output format to %s for profile %s", format, profileName)) + }, +} + +var profileMigrateCmd = &cobra.Command{ + Use: "migrate", + Short: "Migrate all profiles from keyring to filesystem.", + Long: profileMigrationLongDesc, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + err := profiles.Migrate(profiles.ProfileDriverFileSystem, profiles.ProfileDriverKeyring) + if err != nil { + c.ExitWithError("Failed to migrate", err) + } + c.ExitWithMessage("Migration complete.", cli.ExitCodeSuccess) + }, +} + +var profileKeyringCleanupCmd = &cobra.Command{ + Use: "cleanup", + Short: "Remove all profiles and configuration from the keyring store. Use when migration fails.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + + force := c.Flags.GetOptionalBool("force") + cli.ConfirmAction(cli.ActionDelete, "all profiles and configuration stored in the keyring", config.AppName, force) + + keyringProfiler, err := osprofiles.New(config.AppName, osprofiles.WithKeyringStore()) + if err != nil { + c.ExitWithError("Failed to initialize keyring profile store", err) + } + + if err := keyringProfiler.Cleanup(force); err != nil { + cli.ExitWithError(profiles.ErrCleaningUpProfiles.Error(), err) + } + c.ExitWithMessage("Keyring profile store cleanup complete", cli.ExitCodeSuccess) + }, +} + +func InitProfileCommands() { + profileCreateCmd.Flags().Bool("set-default", false, "Set the profile as default") + profileCreateCmd.Flags().Bool("tls-no-verify", false, "Disable TLS verification") + profileCreateCmd.Flags().String("output-format", profiles.OutputStyled, "Preferred output format: styled or json") + + profileListCmd.Flags().String("store", "filesystem", "Profile store to use: filesystem or keyring") + profileGetCmd.Flags().String("store", "filesystem", "Profile store to use: filesystem or keyring") + profileDeleteCmd.Flags().String("store", "filesystem", "Profile store to use: filesystem or keyring") + profileDeleteAllCmd.Flags().String("store", "filesystem", "Profile store to use: filesystem or keyring") + profileDeleteAllCmd.Flags().Bool("force", false, "Skip confirmation prompt") + + profileSetEndpointCmd.Flags().Bool("tls-no-verify", false, "Disable TLS verification") + + RootCmd.AddCommand(profileCmd) + + profileCmd.AddCommand(profileCreateCmd) + profileCmd.AddCommand(profileListCmd) + profileCmd.AddCommand(profileGetCmd) + profileCmd.AddCommand(profileDeleteCmd) + profileCmd.AddCommand(profileDeleteAllCmd) + profileCmd.AddCommand(profileSetDefaultCmd) + profileCmd.AddCommand(profileSetEndpointCmd) + profileCmd.AddCommand(profileSetOutputFormatCmd) + profileCmd.AddCommand(profileMigrateCmd) + profileCmd.AddCommand(profileKeyringCleanupCmd) + + profileKeyringCleanupCmd.Flags().Bool("force", false, "Skip confirmation prompt") +} diff --git a/otdfctl/cmd/root.go b/otdfctl/cmd/root.go new file mode 100644 index 0000000000..89ab6f9fcb --- /dev/null +++ b/otdfctl/cmd/root.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "fmt" + "log/slog" + "os" + + "github.com/opentdf/platform/otdfctl/cmd/auth" + cfg "github.com/opentdf/platform/otdfctl/cmd/config" + "github.com/opentdf/platform/otdfctl/cmd/dev" + "github.com/opentdf/platform/otdfctl/cmd/migrate" + "github.com/opentdf/platform/otdfctl/cmd/policy" + "github.com/opentdf/platform/otdfctl/cmd/tdf" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/config" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/sdk" + "github.com/spf13/cobra" +) + +var ( + clientCredsFile string + clientCredsJSON string + + RootCmd = &man.Docs.GetDoc("").Command +) + +type version struct { + AppName string `json:"app_name"` + Version string `json:"version"` + CommitSha string `json:"commit_sha"` + BuildTime string `json:"build_time"` + SDKVersion string `json:"sdk_version"` + SchemaVersion string `json:"schema_version"` +} + +func init() { + rootCmd := man.Docs.GetCommand("", man.WithRun(func(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args) + + if c.Flags.GetOptionalBool("version") { + v := version{ + AppName: config.AppName, + Version: config.Version, + CommitSha: config.CommitSha, + BuildTime: config.BuildTime, + SDKVersion: sdk.Version, + SchemaVersion: sdk.TDFSpecVersion, + } + + version := fmt.Sprintf("%s version %s (%s) %s", config.AppName, config.Version, config.BuildTime, config.CommitSha) + slog.Debug("otdfctl version", + slog.String("app", config.AppName), + slog.String("version", config.Version), + slog.String("build_time", config.BuildTime), + slog.String("commit_sha", config.CommitSha), + ) + c.ExitWith(version, v, cli.ExitCodeSuccess, os.Stdout) + return + } + + //nolint:errcheck // error does not need to be checked + cmd.Help() + })) + + RootCmd = &rootCmd.Command + + // Run logger setup for all commands + RootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + c := cli.New(cmd, args) + isDebug := c.Flags.GetOptionalBool("debug") + logLevel := c.Flags.GetOptionalString("log-level") + if isDebug { + logLevel = "DEBUG" + } + + // log-level from flag will take precedence over env var + if logLevel != "" { + l := new(slog.LevelVar) + if err := l.UnmarshalText([]byte(logLevel)); err != nil { + return fmt.Errorf("invalid log level: %s", logLevel) + } + logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: l, + })) + + slog.SetDefault(logger) + } + return nil + } + + RootCmd.AddCommand( + // config + cfg.Cmd, + // tdf + tdf.EncryptCmd, + tdf.DecryptCmd, + tdf.InspectCmd, + // auth + auth.Cmd, + // policy + policy.Cmd, + // migrate + migrate.Cmd, + // dev + dev.Cmd, + ) + + RootCmd.Flags().Bool( + rootCmd.GetDocFlag("version").Name, + rootCmd.GetDocFlag("version").DefaultAsBool(), + rootCmd.GetDocFlag("version").Description, + ) + + RootCmd.PersistentFlags().Bool( + rootCmd.GetDocFlag("json").Name, + rootCmd.GetDocFlag("json").DefaultAsBool(), + rootCmd.GetDocFlag("json").Description, + ) + + RootCmd.PersistentFlags().String( + rootCmd.GetDocFlag("profile").Name, + rootCmd.GetDocFlag("profile").Default, + rootCmd.GetDocFlag("profile").Description, + ) + + RootCmd.PersistentFlags().String( + rootCmd.GetDocFlag("host").Name, + rootCmd.GetDocFlag("host").Default, + rootCmd.GetDocFlag("host").Description, + ) + RootCmd.PersistentFlags().Bool( + rootCmd.GetDocFlag("tls-no-verify").Name, + rootCmd.GetDocFlag("tls-no-verify").DefaultAsBool(), + rootCmd.GetDocFlag("tls-no-verify").Description, + ) + RootCmd.PersistentFlags().String( + rootCmd.GetDocFlag("log-level").Name, + rootCmd.GetDocFlag("log-level").Default, + rootCmd.GetDocFlag("log-level").Description, + ) + RootCmd.PersistentFlags().Bool( + rootCmd.GetDocFlag("debug").Name, + rootCmd.GetDocFlag("debug").DefaultAsBool(), + rootCmd.GetDocFlag("debug").Description, + ) + if err := RootCmd.PersistentFlags().MarkDeprecated(rootCmd.GetDocFlag("debug").Name, "use --log-level"); err != nil { + panic(fmt.Sprintf("failed to mark debug flag deprecated: %v", err)) + } + RootCmd.PersistentFlags().StringVar( + &clientCredsFile, + rootCmd.GetDocFlag("with-client-creds-file").Name, + rootCmd.GetDocFlag("with-client-creds-file").Default, + rootCmd.GetDocFlag("with-client-creds-file").Description, + ) + RootCmd.PersistentFlags().StringVar( + &clientCredsJSON, + rootCmd.GetDocFlag("with-client-creds").Name, + rootCmd.GetDocFlag("with-client-creds").Default, + rootCmd.GetDocFlag("with-client-creds").Description, + ) + RootCmd.PersistentFlags().String( + rootCmd.GetDocFlag("with-access-token").Name, + rootCmd.GetDocFlag("with-access-token").Default, + rootCmd.GetDocFlag("with-access-token").Description, + ) + RootCmd.AddGroup(&cobra.Group{ID: tdf.GroupID}) + + // Initialize all subcommands that have been refactored to use explicit initialization + cfg.InitCommands() + auth.InitCommands() + migrate.InitCommands() + policy.InitCommands() + dev.InitCommands() + tdf.InitEncryptCommand() + tdf.InitDecryptCommand() + tdf.InitInspectCommand() + InitProfileCommands() + + // Add interactive command + RootCmd.AddCommand(newInteractiveCmd()) +} diff --git a/otdfctl/cmd/tdf/decrypt.go b/otdfctl/cmd/tdf/decrypt.go new file mode 100644 index 0000000000..f6cd7beded --- /dev/null +++ b/otdfctl/cmd/tdf/decrypt.go @@ -0,0 +1,138 @@ +package tdf + +import ( + "errors" + "fmt" + "os" + + "github.com/opentdf/platform/lib/ocrypto" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/otdfctl/pkg/utils" + "github.com/spf13/cobra" +) + +var ( + assertionVerification string + kasAllowList []string + + decryptDoc = man.Docs.GetCommand("decrypt", man.WithRun(decryptRun)) + DecryptCmd = &decryptDoc.Command +) + +func decryptRun(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args, cli.WithPrintJSON()) + h := common.NewHandler(c) + defer h.Close() + + output := c.Flags.GetOptionalString("out") + disableAssertionVerification := c.Flags.GetOptionalBool("no-verify-assertions") + sessionKeyAlgStr := c.Flags.GetOptionalString("session-key-algorithm") + var sessionKeyAlgorithm ocrypto.KeyType + switch sessionKeyAlgStr { + case string(ocrypto.RSA2048Key): + sessionKeyAlgorithm = ocrypto.RSA2048Key + case string(ocrypto.EC256Key): + sessionKeyAlgorithm = ocrypto.EC256Key + case string(ocrypto.EC384Key): + sessionKeyAlgorithm = ocrypto.EC384Key + case string(ocrypto.EC521Key): + sessionKeyAlgorithm = ocrypto.EC521Key + default: + sessionKeyAlgorithm = ocrypto.RSA2048Key + } + + // check for piped input + piped := readPipedStdin() + + // Prefer file argument over piped input over default filename + bytesToDecrypt := piped + var tdfFile string + var err error + if len(args) > 0 { + tdfFile = args[0] + bytesToDecrypt, err = utils.ReadBytesFromFile(tdfFile, MaxFileSize) + if err != nil { + cli.ExitWithError("Failed to read file:", err) + } + } + + if len(bytesToDecrypt) == 0 { + cli.ExitWithError("Must provide ONE of the following to decrypt: [file argument, stdin input]", errors.New("no input provided")) + } + + ignoreAllowlist := len(kasAllowList) == 1 && kasAllowList[0] == "*" + + decrypted, err := h.DecryptBytes( + c.Context(), + bytesToDecrypt, + assertionVerification, + disableAssertionVerification, + sessionKeyAlgorithm, + kasAllowList, + ignoreAllowlist, + nil, + ) + if err != nil { + cli.ExitWithError("Failed to decrypt file", err) + } + + if output == "" { + //nolint:forbidigo // printing decrypted content to stdout + fmt.Print(decrypted.String()) + return + } + // Here 'output' is the filename given with -o + f, err := os.Create(output) + if err != nil { + cli.ExitWithError("Failed to write decrypted data to file", err) + } + defer f.Close() + _, err = f.Write(decrypted.Bytes()) + if err != nil { + cli.ExitWithError("Failed to write decrypted data to file", err) + } +} + +func InitDecryptCommand() { + decryptDoc.Flags().StringP( + decryptDoc.GetDocFlag("out").Name, + decryptDoc.GetDocFlag("out").Shorthand, + decryptDoc.GetDocFlag("out").Default, + decryptDoc.GetDocFlag("out").Description, + ) + // deprecated flag + decryptDoc.Flags().StringP( + decryptDoc.GetDocFlag("tdf-type").Name, + decryptDoc.GetDocFlag("tdf-type").Shorthand, + decryptDoc.GetDocFlag("tdf-type").Default, + decryptDoc.GetDocFlag("tdf-type").Description, + ) + decryptDoc.Flags().StringVarP( + &assertionVerification, + decryptDoc.GetDocFlag("with-assertion-verification-keys").Name, + decryptDoc.GetDocFlag("with-assertion-verification-keys").Shorthand, + "", + decryptDoc.GetDocFlag("with-assertion-verification-keys").Description, + ) + decryptDoc.Flags().String( + decryptDoc.GetDocFlag("session-key-algorithm").Name, + decryptDoc.GetDocFlag("session-key-algorithm").Default, + decryptDoc.GetDocFlag("session-key-algorithm").Description, + ) + decryptDoc.Flags().Bool( + decryptDoc.GetDocFlag("no-verify-assertions").Name, + decryptDoc.GetDocFlag("no-verify-assertions").DefaultAsBool(), + decryptDoc.GetDocFlag("no-verify-assertions").Description, + ) + decryptDoc.Flags().StringSliceVarP( + &kasAllowList, + decryptDoc.GetDocFlag("kas-allowlist").Name, + decryptDoc.GetDocFlag("kas-allowlist").Shorthand, + nil, + decryptDoc.GetDocFlag("kas-allowlist").Description, + ) + + decryptDoc.GroupID = TDF +} diff --git a/otdfctl/cmd/tdf/encrypt.go b/otdfctl/cmd/tdf/encrypt.go new file mode 100644 index 0000000000..3935825f28 --- /dev/null +++ b/otdfctl/cmd/tdf/encrypt.go @@ -0,0 +1,195 @@ +package tdf + +import ( + "io" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/gabriel-vasile/mimetype" + "github.com/opentdf/platform/lib/ocrypto" + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/otdfctl/pkg/utils" + "github.com/spf13/cobra" +) + +var ( + attrValues []string + assertions string + + encryptDoc = man.Docs.GetCommand("encrypt", man.WithRun(encryptRun)) + EncryptCmd = &encryptDoc.Command +) + +func encryptRun(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args, cli.WithPrintJSON()) + h := common.NewHandler(c) + defer h.Close() + + var filePath string + var fileExt string + if len(args) > 0 { + filePath = args[0] + fileExt = strings.ToLower(strings.TrimPrefix(filepath.Ext(filePath), ".")) + } + + out := c.Flags.GetOptionalString("out") + fileMimeType := c.Flags.GetOptionalString("mime-type") + attrValues = c.Flags.GetStringSlice("attr", attrValues, cli.FlagsStringSliceOptions{Min: 0}) + tdfType := c.Flags.GetOptionalString("tdf-type") + kasURLPath := c.Flags.GetOptionalString("kas-url-path") + wrappingKeyAlgStr := c.Flags.GetOptionalString("wrapping-key-algorithm") + targetMode := c.Flags.GetOptionalString("target-mode") + var wrappingKeyAlgorithm ocrypto.KeyType + switch wrappingKeyAlgStr { + case string(ocrypto.RSA2048Key): + wrappingKeyAlgorithm = ocrypto.RSA2048Key + case string(ocrypto.EC256Key): + wrappingKeyAlgorithm = ocrypto.EC256Key + case string(ocrypto.EC384Key): + wrappingKeyAlgorithm = ocrypto.EC384Key + case string(ocrypto.EC521Key): + wrappingKeyAlgorithm = ocrypto.EC521Key + default: + wrappingKeyAlgorithm = ocrypto.RSA2048Key + } + + piped := readPipedStdin() + + inputCount := 0 + if filePath != "" { + inputCount++ + } + if len(piped) > 0 { + inputCount++ + } + + cliExit := func(s string) { + cli.ExitWithError("Must provide "+s+" of the following to encrypt: [file argument, stdin input]", nil) + } + if inputCount == 0 { + cliExit("ONE") + } else if inputCount > 1 { + cliExit("ONLY ONE") + } + + // prefer filepath argument over stdin input + bytesSlice := piped + var err error + if filePath != "" { + bytesSlice, err = utils.ReadBytesFromFile(filePath, MaxFileSize) + if err != nil { + cli.ExitWithError("Failed to read file:", err) + } + } + + // auto-detect mime type if not provided + if fileMimeType == "" { + slog.Debug("detecting mime type of file") + // get the mime type of the file + mimetype.SetLimit(Size1MB) // limit to 1MB + m := mimetype.Detect(bytesSlice) + // default to application/octet-stream if no mime type is detected + fileMimeType = m.String() + + if fileMimeType == "application/octet-stream" { + if fileExt != "" { + fileMimeType = mimetype.Lookup(fileExt).String() + } + } + } + slog.Debug("encrypting file", + slog.Int("file_len", len(bytesSlice)), + slog.String("mime_type", fileMimeType), + ) + + // Do the encryption + encrypted, err := h.EncryptBytes( + tdfType, + bytesSlice, + attrValues, + fileMimeType, + kasURLPath, + assertions, + wrappingKeyAlgorithm, + targetMode, + ) + if err != nil { + cli.ExitWithError("Failed to encrypt", err) + } + + // Find the destination as the output flag filename or stdout + var dest *os.File + if out != "" { + // make sure output ends in .tdf extension + if !strings.HasSuffix(out, ".tdf") { + out += ".tdf" + } + tdfFile, err := os.Create(out) + if err != nil { + cli.ExitWithError("Failed to write encrypted file "+out, err) + } + defer tdfFile.Close() + dest = tdfFile + } else { + dest = os.Stdout + } + + _, e := io.Copy(dest, encrypted) + if e != nil { + cli.ExitWithError("Failed to write encrypted data to stdout", e) + } +} + +func InitEncryptCommand() { + encryptDoc.Flags().StringP( + encryptDoc.GetDocFlag("out").Name, + encryptDoc.GetDocFlag("out").Shorthand, + encryptDoc.GetDocFlag("out").Default, + encryptDoc.GetDocFlag("out").Description, + ) + encryptDoc.Flags().StringSliceVarP( + &attrValues, + encryptDoc.GetDocFlag("attr").Name, + encryptDoc.GetDocFlag("attr").Shorthand, + []string{}, + encryptDoc.GetDocFlag("attr").Description, + ) + encryptDoc.Flags().StringVarP( + &assertions, + encryptDoc.GetDocFlag("with-assertions").Name, + encryptDoc.GetDocFlag("with-assertions").Shorthand, + "", + encryptDoc.GetDocFlag("with-assertions").Description, + ) + encryptDoc.Flags().String( + encryptDoc.GetDocFlag("mime-type").Name, + encryptDoc.GetDocFlag("mime-type").Default, + encryptDoc.GetDocFlag("mime-type").Description, + ) + encryptDoc.Flags().String( + encryptDoc.GetDocFlag("tdf-type").Name, + encryptDoc.GetDocFlag("tdf-type").Default, + encryptDoc.GetDocFlag("tdf-type").Description, + ) + encryptDoc.Flags().StringP( + encryptDoc.GetDocFlag("wrapping-key-algorithm").Name, + encryptDoc.GetDocFlag("wrapping-key-algorithm").Shorthand, + encryptDoc.GetDocFlag("wrapping-key-algorithm").Default, + encryptDoc.GetDocFlag("wrapping-key-algorithm").Description, + ) + encryptDoc.Flags().String( + encryptDoc.GetDocFlag("kas-url-path").Name, + encryptDoc.GetDocFlag("kas-url-path").Default, + encryptDoc.GetDocFlag("kas-url-path").Description, + ) + encryptDoc.Flags().String( + encryptDoc.GetDocFlag("target-mode").Name, + encryptDoc.GetDocFlag("target-mode").Default, + encryptDoc.GetDocFlag("target-mode").Description, + ) + encryptDoc.GroupID = TDF +} diff --git a/otdfctl/cmd/tdf/inspect.go b/otdfctl/cmd/tdf/inspect.go new file mode 100644 index 0000000000..a35a3e0d04 --- /dev/null +++ b/otdfctl/cmd/tdf/inspect.go @@ -0,0 +1,91 @@ +package tdf + +import ( + "errors" + + "github.com/opentdf/platform/otdfctl/cmd/common" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/handlers" + "github.com/opentdf/platform/otdfctl/pkg/man" + "github.com/opentdf/platform/sdk" + "github.com/spf13/cobra" +) + +type tdfInspectManifest struct { + Algorithm string `json:"algorithm"` + KeyAccessType string `json:"keyAccessType"` + MimeType string `json:"mimeType"` + Policy string `json:"policy"` + Protocol string `json:"protocol"` + SegmentHashAlgorithm string `json:"segmentHashAlgorithm"` + Signature string `json:"signature"` + Type string `json:"type"` + Method sdk.Method `json:"method"` + IntegrityInformation sdk.IntegrityInformation `json:"integrityInformation"` + EncryptionInformation sdk.EncryptionInformation `json:"encryptionInformation"` + Assertions []sdk.Assertion `json:"assertions,omitempty"` + SchemaVersion string `json:"schemaVersion,omitempty"` +} + +type tdfInspectResult struct { + Manifest tdfInspectManifest `json:"manifest"` + Attributes []string `json:"attributes"` +} + +var ( + inspectDoc = man.Docs.GetCommand("inspect", man.WithRun(inspectRun)) + InspectCmd = &inspectDoc.Command +) + +func inspectRun(cmd *cobra.Command, args []string) { + c := cli.New(cmd, args, cli.WithPrintJSON()) + h := common.NewHandler(c) + defer h.Close() + + data := cli.ReadFromArgsOrPipe(args, nil) + if len(data) == 0 { + c.ExitWithError("must provide ONE of the following: [file argument, stdin input]", errors.New("no input provided")) + } + + result, errs := h.InspectTDF(data) + for _, err := range errs { + if errors.Is(err, handlers.ErrTDFInspectFailNotValidTDF) { + c.ExitWithError("not a valid TDF", err) + } else if errors.Is(err, handlers.ErrTDFInspectFailNotInspectable) { + c.ExitWithError("failed to inspect TDF", err) + } + } + + if result.ZTDFManifest != nil { + m := tdfInspectResult{ + Manifest: tdfInspectManifest{ + Algorithm: result.ZTDFManifest.Algorithm, + KeyAccessType: result.ZTDFManifest.KeyAccessType, + MimeType: result.ZTDFManifest.MimeType, + Policy: result.ZTDFManifest.Policy, + Protocol: result.ZTDFManifest.Protocol, + SegmentHashAlgorithm: result.ZTDFManifest.SegmentHashAlgorithm, + Signature: result.ZTDFManifest.Signature, + Type: result.ZTDFManifest.Type, + Method: result.ZTDFManifest.Method, + IntegrityInformation: result.ZTDFManifest.IntegrityInformation, + EncryptionInformation: result.ZTDFManifest.EncryptionInformation, + Assertions: result.ZTDFManifest.Assertions, + SchemaVersion: result.ZTDFManifest.TDFVersion, + }, + Attributes: result.Attributes, + } + + c.ExitWithJSON(m, cli.ExitCodeSuccess) + } + c.ExitWithError("failed to inspect TDF", nil) +} + +func InitInspectCommand() { + inspectDoc.GroupID = TDF + + inspectDoc.PreRun = func(cmd *cobra.Command, args []string) { + // Set the json flag to true since we only support json output + cmd.SetArgs(append(args, "--json")) + } +} diff --git a/otdfctl/cmd/tdf/tdf.go b/otdfctl/cmd/tdf/tdf.go new file mode 100644 index 0000000000..7f727af92b --- /dev/null +++ b/otdfctl/cmd/tdf/tdf.go @@ -0,0 +1,31 @@ +package tdf + +import ( + "io" + "os" + + "github.com/opentdf/platform/otdfctl/pkg/cli" +) + +const ( + Size1MB = 1024 * 1024 + MaxFileSize = int64(10 * 1024 * 1024 * 1024) // 10 GB + TDF = "TDF" + // GroupID is the group ID for TDF commands + GroupID = TDF +) + +func readPipedStdin() []byte { + stat, err := os.Stdin.Stat() + if err != nil { + cli.ExitWithError("Failed to read stat from stdin", err) + } + if (stat.Mode() & os.ModeCharDevice) == 0 { + buf, err := io.ReadAll(os.Stdin) + if err != nil { + cli.ExitWithError("failed to scan bytes from stdin", err) + } + return buf + } + return nil +} diff --git a/otdfctl/docs/README.md b/otdfctl/docs/README.md new file mode 100644 index 0000000000..c19cef1218 --- /dev/null +++ b/otdfctl/docs/README.md @@ -0,0 +1,6 @@ +# otdfctl - OpenTDF Control Tool Documentation + +This directory contains the manual pages for the OpenTDF Control Tool (otdfctl). These docs are used +to drive the help system for the tool and provide a way to support internationalization. + +The docs are published to the [OpenTDF docs website](https://opentdf.github.io/docs/category/cli). diff --git a/otdfctl/docs/main.go b/otdfctl/docs/main.go new file mode 100644 index 0000000000..3e6f48258e --- /dev/null +++ b/otdfctl/docs/main.go @@ -0,0 +1,6 @@ +package docs + +import "embed" + +//go:embed all:man/* +var ManFiles embed.FS diff --git a/otdfctl/docs/man/_index.md b/otdfctl/docs/man/_index.md new file mode 100644 index 0000000000..bd72aa8c29 --- /dev/null +++ b/otdfctl/docs/man/_index.md @@ -0,0 +1,46 @@ +--- +title: otdfctl - OpenTDF Control Tool + +command: + name: otdfctl + flags: + - name: version + description: show version + default: false + - name: profile + description: profile to use for interacting with the platform + default: + - name: host + description: Hostname of the platform (i.e. https://localhost) + default: + - name: tls-no-verify + description: disable verification of the server's TLS certificate + default: false + - name: log-level + description: log level, default level is INFO + enum: + - debug + - info + - warn + - error + - name: with-access-token + description: access token for authentication via bearer token + - name: with-client-creds-file + description: path to a JSON file containing a 'clientId', 'clientSecret', and optional 'scopes' for auth via client-credentials flow + - name: with-client-creds + description: JSON string containing a 'clientId', 'clientSecret', and optional 'scopes' for auth via client-credentials flow + default: "" + - name: json + description: output in JSON format + default: false + - name: debug + description: DEPRECATED Use log-level. Setting this will enable debug logs + default: false +--- + +**Note**: Starting with version 1.67 of go-grpc, ALPN (Application-Layer Protocol Negotiation) is now enforced. + +To work around this, you can either: + +- Disable ALPN enforcement by setting the following environment variable: `export GRPC_ENFORCE_ALPN_ENABLED=false` +- Enable HTTP/2 on your load balancer. diff --git a/otdfctl/docs/man/auth/_index.md b/otdfctl/docs/man/auth/_index.md new file mode 100644 index 0000000000..909ba22f31 --- /dev/null +++ b/otdfctl/docs/man/auth/_index.md @@ -0,0 +1,12 @@ +--- +title: Manage local authentication session + +command: + name: auth +--- + +> [!NOTE] +> Requires experimental profiles feature. (Linux not yet supported. Windows is brittle.) + +The auth commands facilitate the process of authenticating the user with the system using profiles to store the +credentials. diff --git a/otdfctl/docs/man/auth/clear-client-credentials.md b/otdfctl/docs/man/auth/clear-client-credentials.md new file mode 100644 index 0000000000..d24b0ae420 --- /dev/null +++ b/otdfctl/docs/man/auth/clear-client-credentials.md @@ -0,0 +1,13 @@ +--- +title: Clear the cached client credentials + +command: + name: clear-client-credentials + flags: + - name: all + description: Deprecated -- see the `profile` subcommand + default: false +--- + +> [!WARNING] +> Deprecated. Use the `profile` subcommand to manage profiles and credentials. diff --git a/otdfctl/docs/man/auth/client-credentials.md b/otdfctl/docs/man/auth/client-credentials.md new file mode 100644 index 0000000000..a2a9527066 --- /dev/null +++ b/otdfctl/docs/man/auth/client-credentials.md @@ -0,0 +1,51 @@ +--- +title: Authenticate to the platform with the client-credentials flow + +command: + name: client-credentials + args: + - client-id + arbitrary_args: + - client-secret + flags: + - name: scopes + description: OIDC scopes to request (space-separated). +--- + +> [!NOTE] +> Requires experimental profiles feature. +> +> | OS | Keychain | State | +> | --- | --- | --- | +> | MacOS | Keychain | Stable | +> | Windows | Credential Manager | Alpha | +> | Linux | Secret Service | Not yet supported | + +Allows the user to login in via Client Credentials flow. The client credentials will be stored safely +in the OS keyring for future use. + +## Examples + +Authenticate with client credentials (id and secret provided interactively) + +```shell +otdfctl auth client-credentials +``` + +Authenticate with client credentials (secret provided interactively) + +```shell +otdfctl auth client-credentials +``` + +Authenticate with client credentials (secret provided as argument) + +```shell +otdfctl auth client-credentials +``` + +Authenticate with client credentials and explicit scopes + +```shell +otdfctl auth client-credentials --scopes "api:access:read api:access:write" +``` diff --git a/otdfctl/docs/man/auth/login.md b/otdfctl/docs/man/auth/login.md new file mode 100644 index 0000000000..36b47d0430 --- /dev/null +++ b/otdfctl/docs/man/auth/login.md @@ -0,0 +1,31 @@ +--- +title: Open a browser and login + +command: + name: login + flags: + - name: client-id + description: A clientId for a public (no-secret) IdP client supporting the auth code flow from any localhost port (e.g. cli-client) + shorthand: i + required: true + - name: port + description: A preferred port number to faciliate the auth flow process. + shorthand: p + required: false +--- + +> [!NOTE] +> Requires experimental profiles feature. +> +> | OS | Keychain | State | +> | --- | --- | --- | +> | MacOS | Keychain | Stable | +> | Windows | Credential Manager | Alpha | +> | Linux | Secret Service | Not yet supported | + +Authenticate for use of the OpenTDF Platform through a browser (required). + +Provide a specific public 'client-id' known to support the Auth Code PKCE flow and recognized +by the OpenTDF Platform (e.g. `cli-client`). + +The OIDC Access Token will be stored in the OS-specific keychain by default (Linux not yet supported). diff --git a/otdfctl/docs/man/auth/logout.md b/otdfctl/docs/man/auth/logout.md new file mode 100644 index 0000000000..f92e2a6809 --- /dev/null +++ b/otdfctl/docs/man/auth/logout.md @@ -0,0 +1,19 @@ +--- +title: Clear credentials from profile + +command: + name: logout +--- + + +> [!NOTE] +> Requires experimental profiles feature. +> +> | OS | Keychain | State | +> | --- | --- | --- | +> | MacOS | Keychain | Stable | +> | Windows | Credential Manager | Alpha | +> | Linux | Secret Service | Not yet supported | + +Removes any auth credentials (Client Credentials or an Access Token from a login) +from the current profile. diff --git a/otdfctl/docs/man/auth/print-access-token.md b/otdfctl/docs/man/auth/print-access-token.md new file mode 100644 index 0000000000..50774446da --- /dev/null +++ b/otdfctl/docs/man/auth/print-access-token.md @@ -0,0 +1,21 @@ +--- +title: Print the cached OIDC access token (if found) + +command: + name: print-access-token + flags: + - name: json + description: Print the full token in JSON format + default: false +--- + +> [!NOTE] +> Requires experimental profiles feature. +> +> | OS | Keychain | State | +> | --- | --- | --- | +> | MacOS | Keychain | Stable | +> | Windows | Credential Manager | Alpha | +> | Linux | Secret Service | Not yet supported | + +Retrieves a new OIDC Access Token using the client credentials and prints to stdout if found. diff --git a/otdfctl/docs/man/config/_index.md b/otdfctl/docs/man/config/_index.md new file mode 100644 index 0000000000..247aa36b55 --- /dev/null +++ b/otdfctl/docs/man/config/_index.md @@ -0,0 +1,10 @@ +--- +title: Manage Configuration + +command: + name: config +--- + +## DEPRECATED + +**Please use `profile set-output-format` instead** diff --git a/otdfctl/docs/man/config/output.md b/otdfctl/docs/man/config/output.md new file mode 100644 index 0000000000..7ed18831c4 --- /dev/null +++ b/otdfctl/docs/man/config/output.md @@ -0,0 +1,15 @@ +--- +title: Define the configured output format + +command: + name: output + flags: + - name: format + description: "'json' or 'styled' as the configured output format" + default: "styled" + required: false +--- + +## DEPRECATED + +**Please use `profile set-output-format` instead** diff --git a/otdfctl/docs/man/decrypt/_index.md b/otdfctl/docs/man/decrypt/_index.md new file mode 100644 index 0000000000..2b7c7b6f22 --- /dev/null +++ b/otdfctl/docs/man/decrypt/_index.md @@ -0,0 +1,90 @@ +--- +title: Decrypt a TDF file +command: + name: decrypt [file] + flags: + - name: out + shorthand: o + description: 'The file destination for decrypted content to be written instead of stdout.' + default: '' + - name: tdf-type + shorthand: t + description: Deprecated. TDF type is now auto-detected. + - name: no-verify-assertions + description: disable verification of assertions + default: false + - name: session-key-algorithm + description: > + EXPERIMENTAL: The type of session key algorithm to use for decryption + enum: + - rsa:2048 + - ec:secp256r1 + - ec:secp384r1 + - ec:secp521r1 + default: rsa:2048 + - name: with-assertion-verification-keys + description: > + EXPERIMENTAL: path to JSON file of keys to verify signed assertions. See examples for more information. + - name: kas-allowlist + description: A custom allowlist of comma-separated KAS Urls, e.g. `https://example.com/kas,http://localhost:8080`. If none specified, the platform will use the list of KASes in the KAS registry. To ignore the allowlist, use a quoted wildcard e.g. `--kas-allowlist '*'` **WARNING:** Bypassing the allowlist may expose you to potential security risks, as untrusted KAS URLs could be used. +--- + +Decrypt a Trusted Data Format (TDF) file and output the contents to stdout or a file in the current working directory. + +The first argument is the TDF file with path from the current working directory being decrypted. + +## Examples + +Various ways to decrypt a TDF file + +```shell +# decrypt file and write to standard output +otdfctl decrypt hello.txt.tdf + +# decrypt file and write to hello.txt file +otdfctl decrypt hello.txt.tdf -o hello.txt + +# decrypt piped TDF content and write to hello.txt file +cat hello.txt.tdf | otdfctl decrypt -o hello.txt +``` + +Advanced piping is supported + +```shell +$ echo "hello world" | otdfctl encrypt | otdfctl decrypt | cat +hello world +``` + +## Session Key Algorithm -- EXPERIMENTAL + +The session-key-algorithm specifies the algorithm to use for the session key. The available options are (default: rsa:2048): + +- rsa:2048 +- ec:secp256r1 +- ec:secp384r1 +- ec:secp521r1 + +Example + +```shell +# Decrypt a file using the ec:secp256r1 algorithm for the session key +# EXPERIMENTAL +otdfctl decrypt hello.txt --session-key-algorithm ec:secp256r1 +``` + +### ZTDF Assertion Verification (experimental) + +To verify the signed assertions (metadata bound to the TDF), you can provide verification keys. The supported assertion signing algorithms are HS256 and RS256 so the keys provided should either be an HS256 key or a public RS256 key. + +```shell +# decrypt file and write to standard output +otdfctl decrypt hello.txt.tdf --with-assertion-verification-keys my_assertion_verification_keys.json +``` + +Where my_assertion_verification_keys.json looks like: + +```json +{"keys":{"assertion1":{ "alg":"HS256","key":"k0cn4xBcY+49z5gs4OHUs/kbQ3/T8p+uUW9pIQ/9aqE="},"assertion2":{ "alg":"RS256","key":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCsgKCAQEAmr0wRsdXN0O9NiltxoGy\nC6ZYwHbdiPVzvOnm9ven5g7Fpm3HOmygdi021WX1OlSua+OSrXGPjM2xbY3LTrFH\nQXQEITjraXQRp5vlKDbBnOrtjYDaKazBXgTYVdelE4AIAuQaGoTudMasHBGiLPEW\niTL4ySec0NzHn2s72Q4hn5/KJpIJOGqj0SlNViufdNylkjrJ3apoYFv1Mhwi3EF/\niFZQ5encDDJmcG/UYF3msbuHRzArJJQ733BNRvicWF/nqixKxprvm8Ts8a54tr8N\nZ7cEu1u5G6AY/pZFGk4ml8q3v5o1ja7xw2dgpJlS8Tl88tUzs+7GG8Ib8n7mHqeP\nTQIDAQAB\n-----END PUBLIC KEY-----\n"}}} +``` + +If no verification keys are provided, the SDK will default to verifying using the payload key. If the assertions were not signed with the payload key, the decrypt call will fail. diff --git a/otdfctl/docs/man/dev/_index.md b/otdfctl/docs/man/dev/_index.md new file mode 100644 index 0000000000..50771c912f --- /dev/null +++ b/otdfctl/docs/man/dev/_index.md @@ -0,0 +1,9 @@ +--- +title: Development Tools +command: + name: dev + hidden: true +--- + +Development mode is a primarily used to aid in the development of this CLI. There are other uses for +this command and exploration is encouraged. diff --git a/otdfctl/docs/man/dev/design-system.md b/otdfctl/docs/man/dev/design-system.md new file mode 100644 index 0000000000..498f7f76b5 --- /dev/null +++ b/otdfctl/docs/man/dev/design-system.md @@ -0,0 +1,7 @@ +--- +title: Design System +command: + name: design-system +--- + +The design system is a collection of design tokens, components, and guidelines that are used to create a consistent user experience across all of the CLI's interfaces. The design system is a living document and is subject to change as the CLI evolves. diff --git a/otdfctl/docs/man/dev/selectors/_index.md b/otdfctl/docs/man/dev/selectors/_index.md new file mode 100644 index 0000000000..7d08a52848 --- /dev/null +++ b/otdfctl/docs/man/dev/selectors/_index.md @@ -0,0 +1,10 @@ +--- +title: Selectors +command: + name: selectors + aliases: + - sel +--- + +Commands to generate and test selectors on Subject Entity Representations. For more information, see the help manual for each subcommand +or additional context within Subject Condition Sets. diff --git a/otdfctl/docs/man/dev/selectors/generate.md b/otdfctl/docs/man/dev/selectors/generate.md new file mode 100644 index 0000000000..30eff2c69e --- /dev/null +++ b/otdfctl/docs/man/dev/selectors/generate.md @@ -0,0 +1,45 @@ +--- +title: Generate a set of selector expressions for keys and values of a Subject Context +command: + name: generate + aliases: + - gen + flags: + - name: subject + shorthand: s + description: A Subject Context string (JSON or JWT, default JSON) + default: '' +--- + +Take in an Entity Representation as a JWT or JSON object, such as that provided by +an Identity Provider (idP), LDAP, or OIDC Access Token JWT, and generate +sample selectors employing [flattening syntax](#flattening-syntax) to utilize within +within Subject Condition Sets that resolve an external Subject Context into mapped Attribute +Values. + +# Flattening-syntax + +The platform maintains a very simple flattening library such that the below structure flattens into the key/value pairs beneath. + +Subject input (`--subject`): + +```json +{ + "key": "abc", + "something": { + "nested": "nested_value", + "list": ["item_1", "item_2"] + } +} +``` + +Generated Selectors: + +| Selector | Value | Significance | +| -------------------- | -------------- | ------------------------- | +| ".key" | "abc" | specified field | +| ".something.nested" | "nested_value" | nested field | +| ".something.list[0]" | "item_1" | first index specifically | +| ".something.list[]" | "item_1" | any index in the list | +| ".something.list[1]" | "item_2" | second index specifically | +| ".something.list[]" | "item_2" | any index in the list | diff --git a/otdfctl/docs/man/dev/selectors/test.md b/otdfctl/docs/man/dev/selectors/test.md new file mode 100644 index 0000000000..c30328e01a --- /dev/null +++ b/otdfctl/docs/man/dev/selectors/test.md @@ -0,0 +1,48 @@ +--- +title: Test resolution of a set of selector expressions for keys and values of a Subject Context. +command: + name: test + flags: + - name: subject + shorthand: s + description: A Subject Context string (JSON or JWT, auto-detected) + default: '' + - name: selector + shorthand: x + description: "Individual selectors to test against the Subject Context (i.e. '.key,.realm_access.roles[]')" +--- + +Test a subject Entity Representation as a JWT or JSON object, such as that provided by +an Identity Provider (idP), LDAP, or OIDC Access Token JWT, against provided selectors employing [flattening syntax](#flattening-syntax) to +validate their resolution to field values on the subject's entity representation. + +# Flattening-syntax + +The platform maintains a very simple flattening library such that the below structure flattens into the key/value pairs beneath. + +Original: + +```json +{ + "key": "abc", + "something": { + "nested": "nested_value", + "list": ["item_1", "item_2"] + } +} +``` + +Flattened: + +| Selector | Value | Significance | +| -------------------- | -------------- | ------------------------- | +| ".key" | "abc" | specified field | +| ".something.nested" | "nested_value" | nested field | +| ".something.list[0]" | "item_1" | first index specifically | +| ".something.list[]" | "item_1" | any index in the list | +| ".something.list[1]" | "item_2" | second index specifically | +| ".something.list[]" | "item_2" | any index in the list | + +Testing the example above with `--selector '.key'` would find the value `abc` on the `key` field and return it in the command output. + +Testing the example above with `--selector .values[]` would not find a list at a field named `values` because it is missing entirely from the input object. diff --git a/otdfctl/docs/man/encrypt/_index.md b/otdfctl/docs/man/encrypt/_index.md new file mode 100644 index 0000000000..36fa33647e --- /dev/null +++ b/otdfctl/docs/man/encrypt/_index.md @@ -0,0 +1,135 @@ +--- +title: Encrypt file or stdin as a TDF +command: + name: encrypt [file] + flags: + - name: out + shorthand: o + description: The output file TDF in the current working directory instead of stdout ('-o file.txt' and '-o file.txt.tdf' both write the TDF as file.txt.tdf). + default: '' + - name: attr + shorthand: a + description: Attribute value Fully Qualified Names (FQNs, i.e. 'https://example.com/attr/attr1/value/value1') to apply to the encrypted data. + - name: wrapping-key-algorithm + description: > + EXPERIMENTAL: The algorithm to use for the wrapping key + enum: + - rsa:2048 + - ec:secp256r1 + - ec:secp384r1 + - ec:secp521r1 + default: rsa:2048 + - name: mime-type + description: The MIME type of the input data. If not provided, the MIME type is inferred from the input data. + - name: tdf-type + shorthand: t + description: The type of TDF to encrypt as (tdf3 is an alias for ztdf). + enum: + - ztdf + - tdf3 + default: ztdf + - name: kas-url-path + description: URL path to the KAS service at the platform endpoint domain. Leading slash is required if needed. + default: /kas + - name: target-mode + description: The target TDF spec version (e.g., "4.3.0"); intended for legacy compatibility and subject to removal. + default: "" + - name: with-assertions + description: > + EXPERIMENTAL: JSON string or path to a JSON file of assertions to bind metadata to the TDF. See examples for more information. WARNING: Providing keys in a JSON string is strongly discouraged. If including sensitive keys, instead provide a path to a JSON file containing that information. +--- + +Build a Trusted Data Format (TDF) with encrypted content from a specified file or input from stdin utilizing OpenTDF platform. + +## Examples + +Various ways to encrypt a file + +```shell +# output to stdout +otdfctl encrypt hello.txt + +# output to hello.txt.tdf +otdfctl encrypt hello.txt --out hello.txt.tdf + +# encrypt piped content and write to hello.txt.tdf +cat hello.txt | otdfctl encrypt --out hello.txt.tdf +``` + +Automatically append .tdf to the output file name + +```shell +$ cat hello.txt | otdfctl encrypt --out hello.txt; ls +hello.txt hello.txt.tdf + +$ cat hello.txt | otdfctl encrypt --out hello.txt.tdf; ls +hello.txt hello.txt.tdf +``` + +Advanced piping is supported + +```shell +$ echo "hello world" | otdfctl encrypt | otdfctl decrypt | cat +hello world +``` + +## Wrapping Key Algorithm - EXPERIMENTAL + +The wrapping-key-algorithm specifies the algorithm to use for the wrapping key. The available options are (default: rsa:2048): +- rsa:2048 +- ec:secp256r1 +- ec:secp384r1 +- ec:secp521r1 + +Example +```shell +# Encrypt a file using the ec:secp256r1 algorithm for the wrapping key +# EXPERIMENTAL +otdfctl encrypt hello.txt --wrapping-key-algorithm ec:secp256r1 --out hello.txt.tdf +``` + +## Attributes + +Attributes can be added to the encrypted data. The attribute value is a Fully Qualified Name (FQN) that is used to +restrict access to the data based on entity entitlements. + +```shell +# output to hello.txt.tdf with attribute +otdfctl encrypt hello.txt --out hello.txt.tdf --attr https://example.com/attr/attr1/value/value1 +``` + +## ZTDF Assertions (experimental) + +Assertions are a way to bind metadata to the TDF data object in a cryptographically secure way. The data is signed with the provided signing key, or if none is provided, the payload key. The signing key algorithms supported are HS256 and RS256. + +### STANAG 5636 + +The following example demonstrates how to bind a STANAG 5636 metadata assertion, to the TDF data object. + +```shell +otdfctl encrypt hello.txt --out hello.txt.tdf --with-assertions '[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"}]' +``` + +We also support providing an assertions json file. +You can optionally provide your own signing key. In this example, we provide an RS256 private key. +```json +[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"},"signingKey":{"alg":"RS256","key":"-----BEGIN PRIVATE KEY-----\nMIIEugIBADANBgkqhkiG9w0BAQEFAASCBKQwggSgAgEAAoIBAQCavTBGx1c3Q702\nKW3GgbILpljAdt2I9XO86eb296fmDsWmbcc6bKB2LTbVZfU6VK5r45KtcY+MzbFt\njctOsUdBdAQhOOtpdBGnm+UoNsGc6u2NgNoprMFeBNhV16UTgAgC5BoahO50xqwc\nEaIs8RaJMvjJJ5zQ3MefazvZDiGfn8omkgk4aqPRKU1WK5903KWSOsndqmhgW/Uy\nHCLcQX+IVlDl6dwMMmZwb9RgXeaxu4dHMCsklDvfcE1G+JxYX+eqLErGmu+bxOzx\nrni2vw1ntwS7W7kboBj+lkUaTiaXyre/mjWNrvHDZ2CkmVLxOXzy1TOz7sYbwhvy\nfuYep49NAgMBAAECgf8N2RrYrTRyIZmlzMJZgpc4gCujIqSPjJfEn3D5XC5+w9XA\nu/lfONZbn/9Y6/CeTgRcpYRNKO9QI0pb3RQzgiLBO+/Z1UJjtORxR0gXdJ0XXVTz\ntLWsD4dCycpkyT8snLkMQFdzXXRAefNyYdavOVz0kvCNgGgw606rZhkYbtHUCM3X\nb1LZFcIAYrpftKUXxn+xOcSjIKdqKoUlBW6Yk7iTjJuy/Su63gTJ5PbgKpNvK7Xu\nyzu4L7t2pswE5pWxb7uMMpTujqLNYiaXDlzpy/fPN8EjL1mhKzia365+EJ3uKH8c\nQ9dz/1g36lSQnD/lus0cES9xXzQ6+1izc17dTsECgYEA1XGM4PVxCt4TaApDoT7X\npeLDG9pQW55DQQiix4A/0EmQgxf6WN0uZ4b8lds02JhNBGVUIe2nyTNknV+9styu\nJsKJhq+KjrcHmE8uy18++G2cZuOM2S49p8y0HPA8YBcRBC4fAoKFFG3cmrIJW5Vu\nMzzaN+W3/1h/xdkUTpI1lYkCgYEAuZdHWrMNt96WMUuaSwu2tg3BHaYhSeyIcbwi\nm2mIOeLQ6gGtGqyALC6N/K8Ie8KwkisTI9GqcX8O9FrkZx4RvkQrONUaS4aXEJ28\nEZzwJenybkSuWunypVLMmp/pN7+mZZ7GUaDbXTF6pg4GOrlp6MIUk4plJYGXXumg\nqaXvPqUCgYA0pmvf2etmiN00nsOL9Npw+vyx1CpaTzG7ywuMNqCHGn5hN/rzDKwz\nsWKA/K+OdhMZcH1OWTc4NEsvXryGcFUtDnOqG4cMKS3gbjfWxsnbsf4QizTlJbjj\nuWT8dm4OLeJuq4nOrq9xGKCAMEaKptOmI+6YNzwp6oSqIyAVOY+qMQKBgDM7IlRU\nNwY5qIYlE4uByUcKFvQDRw8r/yI+R+NUx2kLRpZCLjG9yofntgQ5oQLg5HME9vyd\nRQqdg1hKuuAIOeem07OVh/OvTIYmtKK8CsK8iNKNnP+1suiWKarJV8yu19UXdjFU\nURmxreSm3GtbgXPiF2H/AxrOYiWuIk6SYq+NAoGAZy96GLP3HfA41UWFZH6b8ZdP\nM6CXKDDvHOk06S/hwmhvq3UO5lQULZ+pd+aURv/TDF9DXhZIyl1CXqyOYB5IqJjk\nAFI8A9n/naq7GyIZZRjzJu2blhSjW3ukkS/5CO4zJ6HfauSUjQA4u+5RStjeK3zd\nF267fElUPN4+pSOAhPI=\n-----END PRIVATE KEY-----\n"}}] +``` +```shell +otdfctl encrypt hello.txt --out hello.txt.tdf --with-assertions my_assertions_signed_rs256.json +``` +Signing with HS256 is also available. +```json +[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"},"signingKey":{"alg":"HS256","key":"k0cn4xBcY+49z5gs4OHUs/kbQ3/T8p+uUW9pIQ/9aqE="}}] +``` +```shell +otdfctl encrypt hello.txt --out hello.txt.tdf --with-assertions my_assertions_signed_hs256.json +``` + +## Target Mode + +To encrypt with a target tdf spec version, use the `--target-mode` flag. A version < 4.3.0 will include hex encoded signature hashes and will not include a schema version in the manifest. + +```shell +otdfctl encrypt hello.txt --out hello.txt.tdf --target-mode 4.3.0 +``` diff --git a/otdfctl/docs/man/example.xmd b/otdfctl/docs/man/example.xmd new file mode 100644 index 0000000000..8bb962625a --- /dev/null +++ b/otdfctl/docs/man/example.xmd @@ -0,0 +1,26 @@ +--- +title: Example command + +command: + name: example + # short: use the title + # long: uses body of the markdown and prepends the title to it + # example: is not supported since developer can implement in the long description + aliases: + - ex + flags: + - name: flag + short: f + description: A flag that does something + required: false + default: "default value" + # - name: another-flag + # short: a + # description: Another flag that does something else + # type: []string + # required: false + # default: "another default value" +--- + +Long description of the command goes here. This is where you can describe what the command does, how +it works, and what the user can expect when they run it. diff --git a/otdfctl/docs/man/inspect/_index.md b/otdfctl/docs/man/inspect/_index.md new file mode 100644 index 0000000000..46a1ec73f4 --- /dev/null +++ b/otdfctl/docs/man/inspect/_index.md @@ -0,0 +1,18 @@ +--- +title: Inspect a TDF file +command: + name: inspect [file] + flags: +--- + +# Inspect a TDF file + +Prints the `manifest.json` of the specified TDF for inspection. + +This is useful for development and administration. + +## Example + +```shell +$ otdfctl inspect example.tdf +``` diff --git a/otdfctl/docs/man/interactive.md b/otdfctl/docs/man/interactive.md new file mode 100644 index 0000000000..656738d4f6 --- /dev/null +++ b/otdfctl/docs/man/interactive.md @@ -0,0 +1,8 @@ +--- +title: Interactive Mode (experimental) + +command: + name: interactive + aliases: + - i +--- diff --git a/otdfctl/docs/man/migrate/_index.md b/otdfctl/docs/man/migrate/_index.md new file mode 100644 index 0000000000..6d93047e98 --- /dev/null +++ b/otdfctl/docs/man/migrate/_index.md @@ -0,0 +1,36 @@ +--- +title: Migrate resources + +command: + name: migrate + aliases: + - migration + description: Migrate policy resources + flags: + - name: commit + shorthand: c + description: Writes changes to policy storage + default: false + - name: interactive + shorthand: i + description: Interactive walk through of migrations + default: false +--- + +`migrate` groups commands used to migrate policy resources and related state. + +The end-to-end workflow is not implemented yet, but the command surface is in place. + +Available subcommands currently include `namespaced-policy` for migration planning and execution, and `prune` for cleanup flows. + +The parent `migrate` command owns the shared `--commit` and `--interactive` flags. + +`migrate prune` is separate from the existing destructive `otdfctl policy subject-condition-sets prune` command. + +## Planned examples + +```shell +otdfctl migrate namespaced-policy --scope=registered-resources --output=policy-migration.json +otdfctl migrate prune namespaced-policy --scope=registered-resources +otdfctl migrate namespaced-policy --scope=actions,subject-mappings,registered-resources --output=policy-migration.json --commit +``` diff --git a/otdfctl/docs/man/migrate/namespaced-policy.md b/otdfctl/docs/man/migrate/namespaced-policy.md new file mode 100644 index 0000000000..d166333378 --- /dev/null +++ b/otdfctl/docs/man/migrate/namespaced-policy.md @@ -0,0 +1,38 @@ +--- +title: Migrate Namespaced Policy + +command: + name: namespaced-policy + flags: + - name: scope + shorthand: s + description: "Comma-separated scopes: actions, subject-condition-sets, subject-mappings, registered-resources, obligation-triggers" + default: '' + - name: output + shorthand: o + description: Path to the migration manifest JSON artifact + default: '' +--- + +`namespaced-policy` is the migration entrypoint for moving legacy policy objects into namespaced policy. + +Dry-run planning is implemented. The command writes the executable migration plan JSON to `--output`. + +`--scope` is required and selects any subset of `actions`, `subject-condition-sets`, `subject-mappings`, `registered-resources`, and `obligation-triggers`. + +`--output` is required and specifies where the plan JSON is written. + +The parent `migrate` command provides the shared `--commit` and `--interactive` flags. + +`--commit` is not implemented yet for `namespaced-policy`. The current workflow is dry-run only. + +`namespaced-policy` is intended to be non-destructive. Commit should create namespaced copies and record migration metadata, but it should not delete legacy objects. Cleanup belongs to `migrate prune`. + +All target namespaces must already exist before the command runs. Planning should fail before any writes if a required namespace is missing. + +## Examples + +```shell +otdfctl migrate namespaced-policy --scope=registered-resources --output=policy-migration.json +otdfctl migrate namespaced-policy --scope=actions,subject-mappings,registered-resources --output=policy-migration.json --commit +``` diff --git a/otdfctl/docs/man/migrate/prune/_index.md b/otdfctl/docs/man/migrate/prune/_index.md new file mode 100644 index 0000000000..145ba179d8 --- /dev/null +++ b/otdfctl/docs/man/migrate/prune/_index.md @@ -0,0 +1,23 @@ +--- +title: Prune Migrated Policy Objects + +command: + name: prune +--- + +`prune` groups commands used to remove policy resources that are no longer needed after migration or cleanup workflows. + +The end-to-end cleanup workflow is not implemented yet, but the command surface is in place. + +Available subcommands currently include `namespaced-policy` for policy cleanup workflows. + +The parent `migrate` command provides the shared `--commit` flag used to apply deletions. + +`migrate prune` is not the same as `otdfctl policy subject-condition-sets prune`. The existing subject-condition-set prune command deletes unmapped subject condition sets. `migrate prune` is only for cleaning up legacy objects after a migration run. + +## Planned examples + +```shell +otdfctl migrate prune namespaced-policy --scope=registered-resources +otdfctl migrate prune namespaced-policy --scope=actions,subject-mappings,registered-resources --commit +``` diff --git a/otdfctl/docs/man/migrate/prune/namespaced-policy.md b/otdfctl/docs/man/migrate/prune/namespaced-policy.md new file mode 100644 index 0000000000..d7d4875716 --- /dev/null +++ b/otdfctl/docs/man/migrate/prune/namespaced-policy.md @@ -0,0 +1,28 @@ +--- +title: Prune Namespaced Policy + +command: + name: namespaced-policy + flags: + - name: scope + shorthand: s + description: "Comma-separated scopes: actions, subject-condition-sets, subject-mappings, registered-resources, obligation-triggers" + default: '' +--- + +`namespaced-policy` is the cleanup entrypoint for namespaced policy migration. + +The command surface is present, but the cleanup workflow is not implemented yet. + +`--scope` is required and selects any subset of `actions`, `subject-condition-sets`, `subject-mappings`, `registered-resources`, and `obligation-triggers`. + +`namespaced-policy` rebuilds the live dependency graph, inspects migration labels, and deletes only legacy objects it can prove are safe to remove for the selected scopes. It does not require a manifest file. + +The parent `migrate` command provides the shared `--commit` flag used to apply deletions. + +## Examples + +```shell +otdfctl migrate prune namespaced-policy --scope=registered-resources +otdfctl migrate prune namespaced-policy --scope=actions,subject-mappings,registered-resources --commit +``` diff --git a/otdfctl/docs/man/policy/_index.md b/otdfctl/docs/man/policy/_index.md new file mode 100644 index 0000000000..a8c76f7756 --- /dev/null +++ b/otdfctl/docs/man/policy/_index.md @@ -0,0 +1,18 @@ +--- +title: Manage policy + +command: + name: policy + aliases: + - pol + - policies + flags: + - name: json + description: output single command in JSON (overrides configured output format) + default: 'false' +--- + +Policy is a set of rules that are enforced by the platform. Specific to the the data-centric +security, policy revolves around data attributes (referred to as attributes). Within the context +of attributes are namespaces, values, subject-mappings, resource-mappings, registered-resources, key-access-server grants, +and other key elements. diff --git a/otdfctl/docs/man/policy/actions/_index.md b/otdfctl/docs/man/policy/actions/_index.md new file mode 100644 index 0000000000..9c2f172575 --- /dev/null +++ b/otdfctl/docs/man/policy/actions/_index.md @@ -0,0 +1,26 @@ +--- +title: Manage Actions +command: + name: actions + aliases: + - action +--- + +Actions are a set of `standard` and `custom` verbs at the core of an Access Decision or an +Obligation. In the context of an entitlement decision, adding Actions to Subject Mappings answers +"what can an Entity _do_ to a Resource?" + +Standard Actions in Policy are comprised of the below, and only their metadata labels are mutable: +- create +- read (considered within all TDF `decrypt` flows) +- update +- delete + +Custom Actions known to Policy are admin-defined, unique within a namespace, and will be lower +cased when stored. They may contain underscores (`_`) or hyphens (`-`) if preceded or followed +by an alphanumeric character. For example: +- download +- queue-to-print +- send_email + +For more information about entitlement and Subject Mappings, see the `subject-mappings` command. diff --git a/otdfctl/docs/man/policy/actions/create.md b/otdfctl/docs/man/policy/actions/create.md new file mode 100644 index 0000000000..b20c6650b5 --- /dev/null +++ b/otdfctl/docs/man/policy/actions/create.md @@ -0,0 +1,36 @@ +--- +title: Create a Custom Action +command: + name: create + aliases: + - c + - add + - new + flags: + - name: name + shorthand: n + description: Name of the custom action (must be unique within a namespace) + required: true + - name: namespace + shorthand: s + description: Namespace ID or FQN + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' +--- + +Add a custom `action` to the platform Policy. + +An Action `name` is normalized to lower case and may contain underscores (`_`) or hyphens (`-`) +between other alphanumeric characters. Each name must be unique within a namespace. + +For more information, see the `actions` subcommand. + +## Examples + +Create a custom action named 'install_package': + +```shell +otdfctl policy actions create --name install_package --namespace https://example.com +``` diff --git a/otdfctl/docs/man/policy/actions/delete.md b/otdfctl/docs/man/policy/actions/delete.md new file mode 100644 index 0000000000..0a67c7061e --- /dev/null +++ b/otdfctl/docs/man/policy/actions/delete.md @@ -0,0 +1,28 @@ +--- +title: Delete a Custom Action +command: + name: delete + flags: + - name: id + shorthand: i + description: ID of the custom action + required: true + - name: force + description: Force deletion without interactive confirmation +--- + +Removes a Custom Action from platform Policy. Standard Actions ('create', 'read', 'update', +'delete'), cannot be deleted. + +Action deletion cascades to any associated entitlement Subject Mappings, Obligations, +and Registered Resource entitlement requirements. + +Make sure you know what you are doing. + +For more information about Actions, see the manual for the `actions` subcommand. + +## Example + +```shell +otdfctl policy actions delete --id 217b300a-47f9-4bee-be8c-d38c880053f7 +``` diff --git a/otdfctl/docs/man/policy/actions/get.md b/otdfctl/docs/man/policy/actions/get.md new file mode 100644 index 0000000000..4ea0a10798 --- /dev/null +++ b/otdfctl/docs/man/policy/actions/get.md @@ -0,0 +1,37 @@ +--- +title: Get a Standard or Custom Action +command: + name: get + aliases: + - g + flags: + - name: id + shorthand: i + description: ID of the action + - name: name + shorthand: n + description: Name of the action + - name: namespace + shorthand: s + description: Namespace ID or FQN +--- + +If both `id` and `name` flag values are provided, `id` is preferred. + +When using `--name`, `--namespace` is required. + +For more information about Actions, see the manual for the `actions` subcommand. + +## Example + +Get by ID: + +```shell +otdfctl policy actions get --id e1402c63-eeaa-45e2-85d2-b939d135941f +``` + +Get by Name: + +```shell +otdfctl policy actions get --name read --namespace https://example.com +``` diff --git a/otdfctl/docs/man/policy/actions/list.md b/otdfctl/docs/man/policy/actions/list.md new file mode 100644 index 0000000000..76e5e987ba --- /dev/null +++ b/otdfctl/docs/man/policy/actions/list.md @@ -0,0 +1,25 @@ +--- +title: List Actions +command: + name: list + aliases: + - l + flags: + - name: namespace + shorthand: s + description: Namespace ID or FQN + - name: limit + shorthand: l + description: Limit retrieved count + - name: offset + shorthand: o + description: Offset (page) quantity from start of the list +--- + +For more information about Actions, see the manual for the `actions` subcommand. + +## Example + +```shell +otdfctl policy actions list --namespace https://example.com +``` diff --git a/otdfctl/docs/man/policy/actions/update.md b/otdfctl/docs/man/policy/actions/update.md new file mode 100644 index 0000000000..5deb085635 --- /dev/null +++ b/otdfctl/docs/man/policy/actions/update.md @@ -0,0 +1,36 @@ +--- +title: Update a Custom Action +command: + name: update + aliases: + - u + flags: + - name: id + shorthand: i + description: ID of the action to update + required: true + - name: name + shorthand: n + description: Optional updated name of the custom action (must be unique within a namespace) + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' + - name: force-replace-labels + description: Destructively replace entire set of existing metadata 'labels' with any provided to this command + default: false +--- + +Update the `name` and/or metadata labels for a Custom Action. + +If PEPs rely on this action name, a name update could break access. + +Make sure you know what you are doing. + +For more information about Actions, see the manual for the `actions` subcommand. + +## Example + +```shell +otdfctl policy actions update --id 34c62145-5d99-45cb-a732-13cb16270e63 --name new_action_name +``` diff --git a/otdfctl/docs/man/policy/attributes/_index.md b/otdfctl/docs/man/policy/attributes/_index.md new file mode 100644 index 0000000000..600ee798f9 --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/_index.md @@ -0,0 +1,13 @@ +--- +title: Manage attributes +command: + name: attributes + aliases: + - attr + - attribute +--- + +Commands to manage attributes within the platform. + +Attributes are used to to define the properties of a piece of data. These attributes will then be +used to define the access controls based on subject encodings and entity entitlements. diff --git a/otdfctl/docs/man/policy/attributes/create.md b/otdfctl/docs/man/policy/attributes/create.md new file mode 100644 index 0000000000..3e3a147ac7 --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/create.md @@ -0,0 +1,73 @@ +--- +title: Create an attribute definition +command: + name: create + aliases: + - new + - add + - c + flags: + - name: name + shorthand: n + description: Name of the attribute + required: true + - name: rule + shorthand: r + description: Rule of the attribute + enum: + - ANY_OF + - ALL_OF + - HIERARCHY + required: true + - name: value + shorthand: v + description: Value of the attribute (i.e. 'value1') + required: true + - name: namespace + shorthand: s + description: Namespace ID of the attribute + required: true + - name: allow-traversal + description: Allow for platform to use the attribute definition when the value is missing during encryption + default: false + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' +--- + +Under a namespace, create an attribute with a rule. An attribute definition `name` is normalized to lower case +and may contain hyphens and underscores between other alphanumeric characters. + +### Rules + +#### ANY_OF + +If an Attribute is defined with logical rule `ANY_OF`, an Entity who is mapped to `any` of the associated Values of the Attribute +on TDF'd Resource Data will be Entitled to take the actions in the mapping. + +#### ALL_OF + +If an Attribute is defined with logical rule `ALL_OF`, an Entity must be mapped to `all` of the associated Values of the Attribute +on TDF'd Resource Data to be Entitled to take the actions in the mapping. + +### HIERARCHY + +If an Attribute is defined with logical rule `HIERARCHY`, an Entity must be mapped to the same level Value or a level above in hierarchy +compared to a given Value on TDF'd Resource Data. Hierarchical values are considered highest at index 0 and lowest at the last index. Actions +propagate down through the hierarchy, so a mapping of a `read` action on the highest level Value on the Attribute will entitle the action +to each hierarchically lower value, and so on. + +For more general information about attributes, see the `attributes` subcommand. + +### Allow Traversal + +Setting the `allow_traversal` flag on an attribute definition allows a TDF to be created with a missing attribute value. +During encryption while `autoconfigure` is true, if the attribute value is missing and the definition has `allow_traversal` +set our system will encrypt using the attribute definitions key, if a key has been mapped to the definition. + +## Example + +```shell +otdfctl policy attributes create --namespace 3d25d33e-2469-4990-a9ed-fdd13ce74436 --name myattribute --rule ANY_OF +``` diff --git a/otdfctl/docs/man/policy/attributes/deactivate.md b/otdfctl/docs/man/policy/attributes/deactivate.md new file mode 100644 index 0000000000..3a35fb9fee --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/deactivate.md @@ -0,0 +1,27 @@ +--- +title: Deactivate an attribute definition +command: + name: deactivate + flags: + - name: id + shorthand: i + description: ID of the attribute + required: true + - name: force + description: Force deactivation without interactive confirmation (dangerous) +--- + +Deactivation preserves uniqueness of the attribute and values underneath within policy and all existing relations, +essentially reserving them. + +However, a deactivation of an attribute means its associated values cannot be entitled in an access decision. + +For information about reactivation, see the `unsafe reactivate` subcommand. + +For more general information about attributes, see the `attributes` subcommand. + +## Example + +```shell +otdfctl policy attributes deactivate --id 3c51a593-cbf8-419d-b7dc-b656d0bedfbb +``` diff --git a/otdfctl/docs/man/policy/attributes/get.md b/otdfctl/docs/man/policy/attributes/get.md new file mode 100644 index 0000000000..ef463c6c52 --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/get.md @@ -0,0 +1,21 @@ +--- +title: Get an attribute definition +command: + name: get + aliases: + - g + flags: + - name: id + shorthand: i + description: ID of the attribute +--- + +Retrieve an attribute along with its metadata, rule, and values. + +For more general information about attributes, see the `attributes` subcommand. + +## Example + +```shell +otdfctl policy attributes get --id=3c51a593-cbf8-419d-b7dc-b656d0bedfbb +``` diff --git a/otdfctl/docs/man/policy/attributes/key/_index.md b/otdfctl/docs/man/policy/attributes/key/_index.md new file mode 100644 index 0000000000..5d3f79da21 --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/key/_index.md @@ -0,0 +1,7 @@ +--- +title: Key Management changes to attribute definition +command: + name: key +--- + +Manages KAS key associations for attribute definitions. diff --git a/otdfctl/docs/man/policy/attributes/key/assign.md b/otdfctl/docs/man/policy/attributes/key/assign.md new file mode 100644 index 0000000000..036df643ca --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/key/assign.md @@ -0,0 +1,26 @@ +--- +title: Assign a KAS key to an attribute definition +command: + name: assign + flags: + - name: attribute + shorthand: a + description: URI or ID of the attribute definition + required: true + - name: key-id + shorthand: k + description: ID of the KAS key to assign + required: true +--- + +Assigns a KAS key to a policy attribute. This enables the attribute to be used with the specified KAS key for encryption and decryption operations. + +## Example + +```shell +otdfctl policy attributes key assign --attribute 3d25d33e-2469-4990-a9ed-fdd13ce74436 --key-id 8f7e6d5c-4b3a-2d1e-9f8d-7c6b5a432f1d +``` + +```shell +otdfctl policy attributes key assign --attribute "https://example.com/attr/example" --key-id 8f7e6d5c-4b3a-2d1e-9f8d-7c6b5a432f1d +``` diff --git a/otdfctl/docs/man/policy/attributes/key/remove.md b/otdfctl/docs/man/policy/attributes/key/remove.md new file mode 100644 index 0000000000..c79da0d472 --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/key/remove.md @@ -0,0 +1,26 @@ +--- +title: Remove a KAS key from an attribute definition +command: + name: remove + flags: + - name: attribute + shorthand: a + description: URI or ID of attribute definition + required: true + - name: key-id + shorthand: k + description: ID of the KAS key to remove + required: true +--- + +Removes a KAS key association from a policy attribute. This will prevent the attribute from being used with the specified KAS key for encryption and decryption operations. + +## Example + +```shell +otdfctl policy attributes key remove --attribute 3d25d33e-2469-4990-a9ed-fdd13ce74436 --key-id 8f7e6d5c-4b3a-2d1e-9f8d-7c6b5a432f1d +``` + +```shell +otdfctl policy attributes key remove --attribute "https://example.com/attr/example" --key-id 8f7e6d5c-4b3a-2d1e-9f8d-7c6b5a432f1d +``` diff --git a/otdfctl/docs/man/policy/attributes/list.md b/otdfctl/docs/man/policy/attributes/list.md new file mode 100644 index 0000000000..8e3af71d5e --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/list.md @@ -0,0 +1,32 @@ +--- +title: List attribute definitions +command: + name: list + aliases: + - l + flags: + - name: state + shorthand: s + description: Filter by state + enum: + - active + - inactive + - any + default: active + - name: limit + shorthand: l + description: Limit retrieved count + - name: offset + shorthand: o + description: Offset (page) quantity from start of the list +--- + +By default, the list will only provide `active` attributes if unspecified, but the filter can be controlled with the `--state` flag. + +For more general information about attributes, see the `attributes` subcommand. + +## Example + +```shell +otdfctl policy attributes list +``` diff --git a/otdfctl/docs/man/policy/attributes/unsafe/_index.md b/otdfctl/docs/man/policy/attributes/unsafe/_index.md new file mode 100644 index 0000000000..0616808c8e --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/unsafe/_index.md @@ -0,0 +1,19 @@ +--- +title: Unsafe changes to attribute definitions +command: + name: unsafe + flags: + - name: force + description: Force unsafe change without confirmation + required: false +--- + +Unsafe changes are dangerous mutations to Policy that can significantly change access behavior around existing attributes +and entitlement. + +Depending on the unsafe change introduced and already existing TDFs, TDFs might become inaccessible that were previously +accessible or vice versa. + +Make sure you know what you are doing. + +For more general information about attributes, see the `attributes` subcommand. diff --git a/otdfctl/docs/man/policy/attributes/unsafe/delete.md b/otdfctl/docs/man/policy/attributes/unsafe/delete.md new file mode 100644 index 0000000000..4b6d0864ea --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/unsafe/delete.md @@ -0,0 +1,26 @@ +--- +title: Delete an attribute definition +command: + name: delete + flags: + - name: id + shorthand: i + description: ID of the attribute definition + required: true +--- + +# Unsafe Delete Warning + +Deleting an Attribute Definition cascades deletion of any Attribute Values and any associated mappings underneath. + +Any existing TDFs containing the deleted attribute of this name will be rendered inaccessible until it has been recreated. + +Make sure you know what you are doing. + +For more general information about attributes, see the `attributes` subcommand. + +## Example + +```shell +otdfctl policy attributes unsafe delete --id 3c51a593-cbf8-419d-b7dc-b656d0bedfbb +``` diff --git a/otdfctl/docs/man/policy/attributes/unsafe/reactivate.md b/otdfctl/docs/man/policy/attributes/unsafe/reactivate.md new file mode 100644 index 0000000000..dad61f816b --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/unsafe/reactivate.md @@ -0,0 +1,26 @@ +--- +title: Reactivate an attribute definition +command: + name: reactivate + flags: + - name: id + shorthand: i + description: ID of the attribute definition + required: true +--- + +# Unsafe Reactivate Warning + +Reactivating an Attribute Definition can potentially open up an access path to any existing TDFs referencing values under that definition. + +The Active/Inactive state of any Attribute Values under this Definition will NOT be changed. + +Make sure you know what you are doing. + +For more general information about attributes, see the `attributes` subcommand. + +## Example + +```shell +otdfctl policy attributes unsafe reactivate --id 3c51a593-cbf8-419d-b7dc-b656d0bedfbb +``` diff --git a/otdfctl/docs/man/policy/attributes/unsafe/update.md b/otdfctl/docs/man/policy/attributes/unsafe/update.md new file mode 100644 index 0000000000..3004839fed --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/unsafe/update.md @@ -0,0 +1,58 @@ +--- +title: Update an attribute definition +command: + name: update + flags: + - name: id + shorthand: i + description: ID of the attribute definition + required: true + - name: name + shorthand: n + description: Name of the attribute definition + - name: rule + shorthand: r + description: Rule of the attribute definition + enum: + - ANY_OF + - ALL_OF + - HIERARCHY + - name: values-order + shorthand: o + description: Order of the attribute values (IDs) + - name: allow-traversal + description: Allow for platform to use the attribute definition when the value is missing during encryption +--- + +# Unsafe Update Warning + +## Name Update + +Renaming an Attribute Definition means any Values and any associated mappings underneath will now be tied to the new name. + +Any existing TDFs containing attributes under the old definition name will be rendered inaccessible, and any TDFs tied to the new name +and already created may now become accessible. + +## Rule Update + +Altering a rule of an Attribute Definition changes the evaluation of entitlement to data. Existing TDFs of the same definition name +and values will now be accessible based on the updated rule. An `anyOf` rule becoming `hierarchy` or vice versa, for example, have +entirely different meanings and access evaluations. + +## Values-Order Update + +In the case of a `hierarchy` Attribute Definition Rule, the order of Values on the attribute has significant impact on data access. +Changing this order (complete, destructive replacement of the existing order) will impact access to data. + +To remove Values from an Attribute Definition, delete them separately via the `values unsafe` commands. To add, utilize safe +`values create` commands. + +Make sure you know what you are doing. + +For more general information about attributes, see the `attributes` subcommand. + +## Example + +```shell +otdfctl policy attributes unsafe update --id 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --name mynewname +``` diff --git a/otdfctl/docs/man/policy/attributes/update.md b/otdfctl/docs/man/policy/attributes/update.md new file mode 100644 index 0000000000..f523cb7d18 --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/update.md @@ -0,0 +1,31 @@ +--- +title: Update an attribute definition +command: + name: update + aliases: + - u + flags: + - name: id + shorthand: i + description: ID of the attribute + required: true + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: "" + - name: force-replace-labels + description: Destructively replace entire set of existing metadata 'labels' with any provided to this command + default: false +--- + +Attribute Definition changes can be dangerous, so this command is for updates considered "safe" (currently just mutations to metadata `labels`). + +For unsafe updates, see the dedicated `unsafe update` command. For more general information, see the `attributes` subcommand. + +For more general information about attributes, see the `attributes` subcommand. + +## Example + +```shell +otdfctl policy attributes update --id=3c51a593-cbf8-419d-b7dc-b656d0bedfbb --label hello=world +``` diff --git a/otdfctl/docs/man/policy/attributes/values/_index.md b/otdfctl/docs/man/policy/attributes/values/_index.md new file mode 100644 index 0000000000..32badd5eb9 --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/values/_index.md @@ -0,0 +1,30 @@ +--- +title: Manage attribute values +command: + name: values + aliases: + - val + - value +--- + +Attribute values are the individual units tagged on TDFs containing Resource Data. + +They are mapped to entitle person and non-person entities through Subject Mappings, to varied terms for tagging providers +through Resource Mappings, to individual keys and Key Access Servers through KAS Grants, and more. + +They are fully-qualified through the FQN structure `https:///attr//value/`, and the presence +of one or more values on a piece of Resource Data (a TDF) determines an entity's access to the data through a combination +of entitlements and the attribute definition rule evaluation. + +In other words, Attribute Values are the atomic units that drive access control relation of Data -> Entities and vice versa. + +Values are contextualized by Attribute Definitions within Namespaces, and only have logical meaning as part of a Definition. + +Giving data multiple Attribute Values across the same or multiple Definitions/Namespaces will require all of the definition rules to be satisfied +by an Entity's mapped Entitlements to result in key release, decryption, and resulting access to TDF'd data. + +For more information on: + +- values, see the `attributes values` subcommand +- attribute definitions, see the `attributes` subcommand +- namespaces, see the `attributes namespaces` subcommand diff --git a/otdfctl/docs/man/policy/attributes/values/create.md b/otdfctl/docs/man/policy/attributes/values/create.md new file mode 100644 index 0000000000..99aa890c76 --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/values/create.md @@ -0,0 +1,35 @@ +--- +title: Create an attribute value +command: + name: create + aliases: + - new + - add + - c + flags: + - name: attribute-id + shorthand: a + description: The ID of the attribute to create a value for + - name: value + shorthand: v + description: The value to create + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' +--- + +Add a single new value underneath an existing attribute. + +An attribute `value` is normalized to lower case and may contain hyphens and underscores +between other alphanumeric characters. + +For a hierarchical attribute, a new value is added in lowest hierarchy (last). + +For more information on attribute values, see the `values` subcommand. + +## Example + +```shell +otdfctl policy attributes values create --attribute-id 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --value myvalue1 +``` diff --git a/otdfctl/docs/man/policy/attributes/values/deactivate.md b/otdfctl/docs/man/policy/attributes/values/deactivate.md new file mode 100644 index 0000000000..b2211c5678 --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/values/deactivate.md @@ -0,0 +1,25 @@ +--- +title: Deactivate an attribute value +command: + name: deactivate + flags: + - name: id + shorthand: i + description: The ID of the attribute value to deactivate + - name: force + description: Force deactivation without interactive confirmation (dangerous) +--- + +Deactivation preserves uniqueness of the attribute value within policy and all existing relations, essentially reserving it. + +However, a deactivation of an attribute value means it cannot be entitled in an access decision. + +For information about reactivation, see the `unsafe reactivate` subcommand. + +For more information on attribute values, see the `values` subcommand. + +## Example + +```shell +otdfctl policy attributes values deactivate --id 355743c1-c0ef-4e8d-9790-d49d883dbc7d +``` diff --git a/otdfctl/docs/man/policy/attributes/values/get.md b/otdfctl/docs/man/policy/attributes/values/get.md new file mode 100644 index 0000000000..51f41c37aa --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/values/get.md @@ -0,0 +1,21 @@ +--- +title: Get an attribute value +command: + name: get + aliases: + - g + flags: + - name: id + shorthand: i + description: The ID of the attribute value to get +--- + +Retrieve an attribute value along with its metadata. + +For more general information about attribute values, see the `values` subcommand. + +## Example + +```shell +otdfctl policy attributes values get --id 355743c1-c0ef-4e8d-9790-d49d883dbc7d +``` diff --git a/otdfctl/docs/man/policy/attributes/values/key/_index.md b/otdfctl/docs/man/policy/attributes/values/key/_index.md new file mode 100644 index 0000000000..0c0eeb2035 --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/values/key/_index.md @@ -0,0 +1,7 @@ +--- +title: Key Management changes to attribute value +command: + name: key +--- + +Manages KAS key associations for attribute values. diff --git a/otdfctl/docs/man/policy/attributes/values/key/assign.md b/otdfctl/docs/man/policy/attributes/values/key/assign.md new file mode 100644 index 0000000000..4932f2eaca --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/values/key/assign.md @@ -0,0 +1,26 @@ +--- +title: Assign a KAS key to an attribute value +command: + name: assign + flags: + - name: value + shorthand: v + description: URI or ID of attribute value + required: true + - name: key-id + shorthand: k + description: ID of the KAS key to assign + required: true +--- + +Assigns a KAS key to a policy attribute value. This enables the attribute value to be used with the specified KAS key for encryption and decryption operations. + +## Example + +```shell +otdfctl policy attributes values assign --value 3d25d33e-2469-4990-a9ed-fdd13ce74436 --key-id 8f7e6d5c-4b3a-2d1e-9f8d-7c6b5a432f1d +``` + +```shell +otdfctl policy attributes values assign --value "https://demo.com/attr/example/value/1" --key-id 8f7e6d5c-4b3a-2d1e-9f8d-7c6b5a432f1d +``` diff --git a/otdfctl/docs/man/policy/attributes/values/key/remove.md b/otdfctl/docs/man/policy/attributes/values/key/remove.md new file mode 100644 index 0000000000..995a70ab4d --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/values/key/remove.md @@ -0,0 +1,26 @@ +--- +title: Remove a KAS key from an attribute value +command: + name: remove + flags: + - name: value + shorthand: v + description: URI or ID of attribute value + required: true + - name: key-id + shorthand: k + description: ID of the KAS key to remove + required: true +--- + +Removes a KAS key from a policy attribute value. After removing the key, the attribute value can no longer be used with the specified KAS key for encryption and decryption operations. + +## Example + +```shell +otdfctl policy attributes values remove --value 3d25d33e-2469-4990-a9ed-fdd13ce74436 --key-id 8f7e6d5c-4b3a-2d1e-9f8d-7c6b5a432f1d +``` + +```shell +otdfctl policy attributes values remove --value "https://example.com/attr/example/value/1" --key-id 8f7e6d5c-4b3a-2d1e-9f8d-7c6b5a432f1d +``` diff --git a/otdfctl/docs/man/policy/attributes/values/list.md b/otdfctl/docs/man/policy/attributes/values/list.md new file mode 100644 index 0000000000..79415aa498 --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/values/list.md @@ -0,0 +1,36 @@ +--- +title: List attribute values +command: + name: list + aliases: + - ls + - l + flags: + - name: attribute-id + shorthand: a + description: The ID of the attribute to list values for + - name: state + shorthand: s + description: Filter by state + enum: + - active + - inactive + - any + default: active + - name: limit + shorthand: l + description: Limit retrieved count + - name: offset + shorthand: o + description: Offset (page) quantity from start of the list +--- + +By default, the list will only provide `active` values if unspecified, but the filter can be controlled with the `--state` flag. + +For more general information about attribute values, see the `values` subcommand. + +## Example + +```shell +otdfctl policy attributes values list --attribute-id 3c51a593-cbf8-419d-b7dc-b656d0bedfbb +``` diff --git a/otdfctl/docs/man/policy/attributes/values/unsafe/_index.md b/otdfctl/docs/man/policy/attributes/values/unsafe/_index.md new file mode 100644 index 0000000000..56857f77da --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/values/unsafe/_index.md @@ -0,0 +1,19 @@ +--- +title: Unsafe changes to attribute values +command: + name: unsafe + flags: + - name: force + description: Force unsafe change without confirmation + required: false +--- + +Unsafe changes are dangerous mutations to Policy that can significantly change access behavior around existing attributes +and entitlement. + +Depending on the unsafe change introduced and already existing TDFs, TDFs might become inaccessible that were previously +accessible or vice versa. + +Make sure you know what you are doing. + +For more information on attribute values, see the `values` subcommand. diff --git a/otdfctl/docs/man/policy/attributes/values/unsafe/delete.md b/otdfctl/docs/man/policy/attributes/values/unsafe/delete.md new file mode 100644 index 0000000000..cc0a48418a --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/values/unsafe/delete.md @@ -0,0 +1,26 @@ +--- +title: Delete an attribute value +command: + name: delete + flags: + - name: id + shorthand: i + description: ID of the attribute value + required: true +--- + +# Unsafe Delete Warning + +Deleting an Attribute Value cascades deletion of any associated mappings underneath. + +Any existing TDFs containing the deleted attribute of this value will be rendered inaccessible until it has been recreated. + +Make sure you know what you are doing. + +For more information on attribute values, see the `values` subcommand. + +## Example + +```shell +otdfctl policy attributes values unsafe delete --id b20458b0-1855-4608-8869-3f6199bc2878 +``` diff --git a/otdfctl/docs/man/policy/attributes/values/unsafe/reactivate.md b/otdfctl/docs/man/policy/attributes/values/unsafe/reactivate.md new file mode 100644 index 0000000000..288aee877e --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/values/unsafe/reactivate.md @@ -0,0 +1,26 @@ +--- +title: Reactivate an attribute value +command: + name: reactivate + flags: + - name: id + shorthand: i + description: ID of the attribute value + required: true +--- + +# Unsafe Reactivate Warning + +Reactivating an Attribute Value can potentially open up an access path to any existing TDFs referencing values under that definition. + +The Active/Inactive state of the Attribute Definition and Namespace above this Value will NOT be changed. + +Make sure you know what you are doing. + +For more information on attribute values, see the `values` subcommand. + +## Example + +```shell +otdfctl policy attributes values unsafe reactivate --id 355743c1-c0ef-4e8d-9790-d49d883dbc7d +``` diff --git a/otdfctl/docs/man/policy/attributes/values/unsafe/update.md b/otdfctl/docs/man/policy/attributes/values/unsafe/update.md new file mode 100644 index 0000000000..c7eba02b65 --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/values/unsafe/update.md @@ -0,0 +1,32 @@ +--- +title: Update an attribute value +command: + name: update + flags: + - name: id + shorthand: i + description: ID of the attribute value + required: true + - name: value + shorthand: v + description: The new value replacing the current value +--- + +# Unsafe Update Warning + +## Value Update + +Changing an Attribute Value means any associated mappings underneath will now be tied to the new value. + +Any existing TDFs containing attributes under the old value will be rendered inaccessible, and any TDFs tied to the new value +and already created may now become accessible. + +Make sure you know what you are doing. + +For more information on attribute values, see the `values` subcommand. + +## Example + +```shell +otdfctl policy attributes values unsafe update --id 355743c1-c0ef-4e8d-9790-d49d883dbc7d --name mynewvalue1 +``` diff --git a/otdfctl/docs/man/policy/attributes/values/update.md b/otdfctl/docs/man/policy/attributes/values/update.md new file mode 100644 index 0000000000..cc31ef71aa --- /dev/null +++ b/otdfctl/docs/man/policy/attributes/values/update.md @@ -0,0 +1,31 @@ +--- +title: Update attribute value + +command: + name: update + aliases: + - u + flags: + - name: id + shorthand: i + description: The ID of the attribute value to update + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' + - name: force-replace-labels + description: Destructively replace entire set of existing metadata 'labels' with any provided to this command + default: false +--- + +Attribute Value changes can be dangerous, so this command is for updates considered "safe" (currently just mutations to metadata `labels`). + +For unsafe updates, see the dedicated `unsafe update` command. For more general information, see the `values` subcommand. + +For more general information about attributes, see the `attributes` subcommand. + +## Example + +```shell +otdfctl policy attributes values update --id 355743c1-c0ef-4e8d-9790-d49d883dbc7d --label hello=world +``` diff --git a/otdfctl/docs/man/policy/kas-grants/_index.md b/otdfctl/docs/man/policy/kas-grants/_index.md new file mode 100644 index 0000000000..ba208ed926 --- /dev/null +++ b/otdfctl/docs/man/policy/kas-grants/_index.md @@ -0,0 +1,121 @@ +--- +title: (Deprecated) Manage Key Access Server grants + +command: + name: kas-grants + aliases: + - kasg + - kas-grant +--- +# Deprecated + +Once Key Access Servers (KASs) have been registered within a platform's policy, +they can be assigned grants to various attribute objects (namespaces, definitions, values). + +> See `kas-registry` command within `policy` to manage the KASs known to the platform. + +Key Access Grants are associations between a registered KAS (see KAS Registry docs) and an Attribute. + +An attribute can be assigned a KAS Grant on its namespace, its definition, or any one of its values. + +Grants enable key split behaviors on TDFs with attributes, which can be useful for various collaboration scenarios around shared policy. + +> [!WARNING] +> KAS Grants are considered experimental, as grants to namespaces are not fully utilized within encrypt/decrypt flows at present. + +## Utilization + +The steps below are driven by the SDK on encrypt, and they are the same steps followed +on decrypt by a KAS making a decision request on a key release (once the decision +is found to be permissible): + +1. look up the attributes on the TDF within the platform +2. find any associated grants for those attributes' values, definitions, namespaces +3. retrieve the public key of each KAS granted to those attribute objects +4. determine based on the specificity matrix below which keys to utilize in splits + +## Specificity + +When KAS grants are considered, they follow a most-to-least specificity matrix. Grants to +Attribute Values supersede any grants to Definitions which also supersede any grants to a Namespace. + +Grants to Attribute Objects: + +| Namespace Grant | Attr Definition Grant | Attr Value Grant | Data Encryption Key Utilized | +| --------------- | --------------------- | ---------------- | ---------------------------- | +| yes | no | no | namespace | +| yes | yes | no | attr definition | +| no | yes | no | attr definition | +| yes | yes | yes | value | +| no | yes | yes | value | +| no | no | yes | value | +| no | no | no | default KAS/platform key | + +> [!NOTE] +> A namespace grant may soon be required with deprecation of a default KAS/platform key. + +## Split Scenarios + +### AnyOf Split + +`Bob` and `Alice` want to share data equally, but maintain their ability to decrypt the data without sharing each other’s private keys. + +With KAS Grants, they can define a key split where the shared data is wrapped with both of their public keys and AnyOf logic, meaning that each partner could decrypt the data with just one of those keys. + +If `Bob` assigns a grant between Bob's running/registered KAS to a known attribute value, and `Alice` defines a grant of Alice's running/registered KAS to the same attribute value, +any data encrypted in a TDF will be decryptable with a key released by _either_ of their Key Access Servers. + +Attribute A: `https://conglomerate.com/attr/organization/value/acmeco` + +Attribute B: `https://conglomerate.com/attr/organization/value/example_inc` + +| Attribute | Namespace | Definition | Value | +| --------- | ---------------- | ------------ | ----------- | +| A | conglomerate.com | organization | acmeco | +| B | conglomerate.com | organization | example_inc | + +**Attribute KAS Grant Scenarios** + +1. Bob & Alice represent individual KAS Grants to attributes on TDF'd data +2. Note that the attributes A and B are of _the same definition and namespace_ + +| Definition: organization | Value: acmeco | Value: example_inc | Split | +| ------------------------ | ------------- | ------------------ | ----- | +| Bob, Alice | - | - | OR | +| - | Bob, Alice | - | OR | +| - | - | Bob, Alice | OR | +| - | Bob | Alice | OR | + +### AllOf Split + +Unlike the `AnyOf` split above, this time `Bob` and `Alice` want to make sure _both_ of their keys must be granted for data in a TDF +to be decrypted. With KAS Grants, they can define a key split where the shared data is wrapped with both of their public keys and +AllOf logic, meaning that neither partner can decrypt the data with just one of those keys. + +To accomplish this, they each define KAS Grants between their KASes and policy attributes, and TDF data with at least two attributes - +one assigned a KAS Grant to Bob's KAS and another assigned a KAS Grant to Alice's KAS. + +Both KASes will need to permit access and release payload keys for the data TDF'd with multiple attributes assigned KAS Grants to be accessible and decrypted. + +Attribute A: `https://conglomerate.com/attr/organization/value/acmeco` + +Attribute B: `https://conglomerate.com/attr/department/value/marketing` + +| Attribute | Namespace | Definition | Value | +| --------- | ---------------- | ------------ | --------- | +| A | conglomerate.com | organization | acmeco | +| A | conglomerate.com | department | marketing | + +**Attribute KAS Grant Scenarios** + +1. Bob & Alice represent individual KAS Grants to attributes on TDF'd data +2. Note that the attributes A and B are of _the same namespace but different definitions_ + +| Definition: A | Value: A | Definition: B | Value: B | Split | +| ------------- | -------- | ------------- | -------- | ----- | +| Bob | - | Alice | - | AND | +| Bob | - | - | Alice | AND | +| - | Bob | - | Alice | AND | + +> [!NOTE] +> Any KAS Grants to attributes across different definitions or namespaces will be `AND` splits. diff --git a/otdfctl/docs/man/policy/kas-grants/assign.md b/otdfctl/docs/man/policy/kas-grants/assign.md new file mode 100644 index 0000000000..dcff8387b1 --- /dev/null +++ b/otdfctl/docs/man/policy/kas-grants/assign.md @@ -0,0 +1,60 @@ +--- +title: (Deprecated) Assign a grant + +command: + name: assign + aliases: + - u + - update + - create + - add + - new + - upsert + description: Assign a grant of a KAS to an Attribute Definition or Value + flags: + - name: namespace-id + shorthand: n + description: The ID of the Namespace being assigned a KAS Grant + - name: attribute-id + shorthand: a + description: The ID of the Attribute Definition being assigned a KAS Grant + required: true + - name: value-id + shorthand: v + description: The ID of the Value being assigned a KAS Grant + required: true + - name: kas-id + shorthand: k + description: The ID of the Key Access Server being assigned to the grant + required: true + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' + - name: force-replace-labels + description: Destructively replace entire set of existing metadata 'labels' with any provided to this command + default: false +--- + +# Deprecated\n\nThis command is deprecated. Use `policy attributes namespace key assign`, `policy attributes key assign`, or `policy attributes value key assign` instead. + +Assign a registered Key Access Server (KAS) to an attribute namespace, definition, or value. + +For more information, see `kas-registry` and `kas-grants` manuals. + +## Example + +Namespace grant: +```shell +otdfctl policy kas-grants assign --namespace-id 3d25d33e-2469-4990-a9ed-fdd13ce74436 --kas-id 62857b55-560c-4b67-96e3-33e4670ecb3b +``` + +Attribute grant: +```shell +otdfctl policy kas-grants assign --attribute-id a21eb299-3a7d-4035-8a39-c8662c03cb15 --kas-id 62857b55-560c-4b67-96e3-33e4670ecb3b +``` + +Attribute value grant: +```shell +otdfctl policy kas-grants assign --value-id 0a40b27c-6cc9-49e8-a6ae-663cac2c324b --kas-id 62857b55-560c-4b67-96e3-33e4670ecb3b +``` diff --git a/otdfctl/docs/man/policy/kas-grants/list.md b/otdfctl/docs/man/policy/kas-grants/list.md new file mode 100644 index 0000000000..1d8e669b52 --- /dev/null +++ b/otdfctl/docs/man/policy/kas-grants/list.md @@ -0,0 +1,34 @@ +--- +title: (Deprecated) List KAS Grants + +command: + name: list + aliases: + - l + description: List the Grants of KASes to Attribute Namespaces, Definitions, and Values + flags: + - name: kas + shorthand: k + description: The optional ID or URI of a KAS to filter the list + - name: limit + shorthand: l + description: Limit retrieved count + - name: offset + shorthand: o + description: Offset (page) quantity from start of the list +--- +# Deprecated\n\nThis command is deprecated and will be removed in a future release. + +List the Grants of Registered Key Access Servers (KASes) to attribute namespaces, definitions, +or values. + +Omitting `kas` lists all grants known to platform policy, otherwise results are filtered to +the KAS URI or ID specified by the flag value. + +For more information, see `kas-registry` and `kas-grants` manuals. + +## Example + +```shell +otdfctl policy kas-grants list +``` diff --git a/otdfctl/docs/man/policy/kas-grants/unassign.md b/otdfctl/docs/man/policy/kas-grants/unassign.md new file mode 100644 index 0000000000..fce822f585 --- /dev/null +++ b/otdfctl/docs/man/policy/kas-grants/unassign.md @@ -0,0 +1,51 @@ +--- +title: (Deprecated) Unassign a grant + +command: + name: unassign + aliases: + - delete + - remove + description: Remove a grant assignment of a KAS to an Attribute Definition or Value + flags: + - name: namespace-id + shorthand: n + description: The ID of the Namespace being unassigned a KAS Grant + - name: attribute-id + shorthand: a + description: The ID of the Attribute Definition being unassigned the KAS grant + required: true + - name: value-id + shorthand: v + description: The ID of the Value being unassigned the KAS Grant + required: true + - name: kas-id + shorthand: k + description: The Key Access Server (KAS) ID being unassigned a grant + required: true + - name: force + description: Force the unassignment with no confirmation +--- + +# Deprecated\n\nThis command is deprecated and will be removed in a future release. Use `policy attributes namespace key remove`, `policy attributes key remove`, or `policy attributes value key remove` instead. + +Unassign a registered Key Access Server (KAS) to an attribute namespace, definition, or value. + +For more information, see `kas-registry` and `kas-grants` manuals. + +## Example + +Namespace grant: +```shell +otdfctl policy kas-grants unassign --namespace-id 3d25d33e-2469-4990-a9ed-fdd13ce74436 --kas-id 62857b55-560c-4b67-96e3-33e4670ecb3b +``` + +Attribute grant: +```shell +otdfctl policy kas-grants unassign --attribute-id a21eb299-3a7d-4035-8a39-c8662c03cb15 --kas-id 62857b55-560c-4b67-96e3-33e4670ecb3b +``` + +Attribute value grant: +```shell +otdfctl policy kas-grants unassign --value-id 0a40b27c-6cc9-49e8-a6ae-663cac2c324b --kas-id 62857b55-560c-4b67-96e3-33e4670ecb3b +``` diff --git a/otdfctl/docs/man/policy/kas-registry/_index.md b/otdfctl/docs/man/policy/kas-registry/_index.md new file mode 100644 index 0000000000..8cbe14638e --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/_index.md @@ -0,0 +1,18 @@ +--- +title: Manage KAS registrations +command: + name: kas-registry + aliases: + - kasr + - kas-registries +--- + +The Key Access Server (KAS) registry is a record of KASes safeguarding access and maintaining public keys. + +The registry contains critical information like each server's uri, its public key (which can be +either cached or at a remote uri), and any metadata about the server. + +Registered Key Access Servers may grant keys for specified Namespaces, Attributes, and their Values via KAS Grants. + +For more information about grants and how KASs are utilized once registered, see the manual for the +`kas-grants` command. diff --git a/otdfctl/docs/man/policy/kas-registry/create.md b/otdfctl/docs/man/policy/kas-registry/create.md new file mode 100644 index 0000000000..4a40452d21 --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/create.md @@ -0,0 +1,34 @@ +--- +title: Create a Key Access Server registration +command: + name: create + aliases: + - c + - add + - new + flags: + - name: uri + shorthand: u + description: URI of the Key Access Server + required: true + - name: public-keys + shorthand: c + description: "(Deprecated: Use otdfctl policy kas-registry keys) One or more public keys saved for the KAS" + - name: public-key-remote + shorthand: r + description: "(Deprecated: Use otdfctl policy kas-registry keys) Remote URI where the public key can be retrieved for the KAS" + - name: label + - name: name + shorthand: n + description: Optional name of the registered KAS (must be unique within Policy) + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' +--- + +## Examples + +```shell +otdfctl policy kas-registry create --uri http://example.com/kas --name example-kas +``` diff --git a/otdfctl/docs/man/policy/kas-registry/delete.md b/otdfctl/docs/man/policy/kas-registry/delete.md new file mode 100644 index 0000000000..d30439c270 --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/delete.md @@ -0,0 +1,28 @@ +--- +title: Delete a Key Access Server registration +command: + name: delete + flags: + - name: id + shorthand: i + description: ID of the Key Access Server registration + required: true + - name: force + description: Force deletion without interactive confirmation (dangerous) +--- + +Removes knowledge of a KAS (registration) from a platform's policy. + +If resource data has been TDFd utilizing key splits from the registered KAS, deletion from +the registry (and therefore any associated grants) may prevent decryption depending on the +type of grants and relevant key splits. + +Make sure you know what you are doing. + +For more information about registration of Key Access Servers, see the manual for `kas-registry`. + +## Example + +```shell +otdfctl policy kas-registry delete --id 3c39618a-cd8c-48cf-a60c-e8a2f4be4dd5 +``` diff --git a/otdfctl/docs/man/policy/kas-registry/get.md b/otdfctl/docs/man/policy/kas-registry/get.md new file mode 100644 index 0000000000..19968e843d --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/get.md @@ -0,0 +1,20 @@ +--- +title: Get a registered Key Access Server +command: + name: get + aliases: + - g + flags: + - name: id + shorthand: i + description: ID of the Key Access Server registration + required: true +--- + +For more information about registration of Key Access Servers, see the manual for `kas-registry`. + +## Example + +```shell +otdfctl policy kas-registry get --id=62857b55-560c-4b67-96e3-33e4670ecb3b +``` diff --git a/otdfctl/docs/man/policy/kas-registry/key/_index.md b/otdfctl/docs/man/policy/kas-registry/key/_index.md new file mode 100644 index 0000000000..9ec4a2011b --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/key/_index.md @@ -0,0 +1,13 @@ +--- +title: Key management for KAS Registry + +command: + name: key + aliases: + - k + - keys +--- + +Provides a set of subcommands for managing cryptographic keys within the Key Access Server (KAS) registry. +These keys are essential for encryption and decryption operations within the OpenTDF platform. +Operations include creating, retrieving, listing, updating, and managing the platform's base key. diff --git a/otdfctl/docs/man/policy/kas-registry/key/base/_index.md b/otdfctl/docs/man/policy/kas-registry/key/base/_index.md new file mode 100644 index 0000000000..b7971eaec5 --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/key/base/_index.md @@ -0,0 +1,14 @@ +--- +title: Platform Base Key Management + +command: + name: base +--- + +Provides subcommands for managing the platform's base cryptographic key. +This base key is a fallback used for encryption operations in specific scenarios: + +- No attributes present when encrypting a file +- No keys associated with an attribute + +Available operations include `get` to retrieve the current base key and `set` to designate a new base key. diff --git a/otdfctl/docs/man/policy/kas-registry/key/base/get.md b/otdfctl/docs/man/policy/kas-registry/key/base/get.md new file mode 100644 index 0000000000..545584010f --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/key/base/get.md @@ -0,0 +1,18 @@ +--- +title: Get Base Key +command: + name: get + aliases: + - g +--- + +Command for retrieving information about the currently configured platform base key. This key is used for encryption operations when no attributes are present or when attributes lack associated keys. + +The command will display details such as the key's identifier (KeyID or UUID) and the Key Access Server (KAS) it is registered with. + +## Examples + +Retrieve the platform base key information in the default (human-readable) format: +``` +otdfctl policy kas-registry key base get +``` diff --git a/otdfctl/docs/man/policy/kas-registry/key/base/set.md b/otdfctl/docs/man/policy/kas-registry/key/base/set.md new file mode 100644 index 0000000000..d672b245ad --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/key/base/set.md @@ -0,0 +1,25 @@ +--- +title: Set Base Key +command: + name: set + aliases: + - s + flags: + - name: key + shorthand: k + description: The KeyID (human-readable identifier) or the internal UUID of an existing key within the specified KAS. This key will be designated as the platform base key. The system will attempt to resolve the provided value as either a UUID or a KeyID. + required: true + - name: kas + description: Specify the Key Access Server (KAS) where the key (identified by `--key`) is registered. The KAS can be identified by its ID, URI, or Name. +--- + +Command for setting a base key to be used for encryption operations on data where no attributes are present or where no keys are present on found attributes. The key to be set as the base key must be identified using its KeyID or UUID via the `--key` flag, and the KAS it belongs to must be specified with the `--kas` flag. + +## Examples + +Set the platform base key using the internal UUID of a key from a KAS specified by its URI: +``` +otdfctl policy kas-registry key base set --key 8af2059f-5d0b-46c2-84f0-bed8a6101d90 --kas https://kas.example.com/kas + +otdfctl policy kas-registry key base set --key my-platform-base-key-v1 --kas primary-key-access-server +``` diff --git a/otdfctl/docs/man/policy/kas-registry/key/create.md b/otdfctl/docs/man/policy/kas-registry/key/create.md new file mode 100644 index 0000000000..7727d187ed --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/key/create.md @@ -0,0 +1,84 @@ +--- +title: Create Key +command: + name: create + aliases: + - c + flags: + - name: key-id + description: A unique, often human-readable, identifier for the new key to be created. + required: true + - name: algorithm + shorthand: a + description: Algorithm for the new key (see table below for options). + required: true + - name: mode + shorthand: m + description: Describes how the private key is managed (see table below for options). + required: true + - name: kas + description: Specify the Key Access Server (KAS) where the new key will be created. The KAS can be identified by its ID, URI, or Name. + required: true + - name: wrapping-key-id + description: Identifier related to the wrapping key. Its meaning depends on the `mode`. For `local` mode, it's a descriptive ID for the `wrappingKey` you provide. For `provider` or `remote` mode, it's the ID of the key within the external provider/system used for wrapping. + - name: wrapping-key + shorthand: w + description: The symmetric key material (AES cipher, hex encoded) used to wrap the generated private key. Primarily used when `mode` is `local`. + - name: private-key-pem + description: The private key PEM (encrypted by an AES 32-byte key, then base64 encoded). Used when importing an existing key pair, typically with `provider` mode. + - name: provider-config-id + shorthand: p + description: Configuration ID for the key provider. Often required when `mode` is `provider` or `remote` and an external key provider is used. + - name: public-key-pem + shorthand: e + description: The base64 encoded public key PEM. Required for `remote` and `public_key` modes, and can be used with `provider` mode if importing an existing key pair. + - name: label + shorthand: l + description: Comma-separated key=value pairs for metadata labels to associate with the new key (e.g., "owner=team-a,env=production"). +--- + +Creates a new cryptographic key within a specified Key Access Server (KAS). +This key is primarily used for encrypting and decrypting data keys in the TDF (Trusted Data Format) ecosystem, forming a crucial part of data protection policies. + +## Examples + +### Create a key in `local` mode + +The KAS generates the key pair, and the private key is wrapped by the provided `wrappingKey`. The KAS is identified by its ID. + +```shell +otdfctl policy kas-registry key create --key-id "aws-key" --algorithm "rsa:2048" --mode "local" --kas 891cfe85-b381-4f85-9699-5f7dbfe2a9ab --wrapping-key-id "virtru-stored-key" --wrapping-key "a8c4824daafcfa38ed0d13002e92b08720e6c4fcee67d52e954c1a6e045907d1" + +otdfctl policy kas-registry key create --key-id "aws-key" --algorithm "rsa:2048" --mode "local" --kas "https://test-kas.com" --wrapping-key-id "virtru-stored-key" --wrapping-key "a8c4824daafcfa38ed0d13002e92b08720e6c4fcee67d52e954c1a6e045907d1" +``` + +```shell +otdfctl policy kas-registry key create --key-id "aws-key" --algorithm "rsa:2048" --mode "provider" --kas "https://test-kas.com" --public-key-pem "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tXG5NSUlDL1RDQ0FlV2dBd0lCQWdJVVNIVEoyYnpBaDdkUW1tRjAzcTZJcS9uMGw5MHdEUVlKS29aSWh2Y05BUUVMXG5CUUF3RGpFTU1Bb0dBMVVFQXd3RGEyRnpNQjRYRFRJME1EWXdOakUzTkRZMU5Gb1hEVEkxTURZd05qRTNORFkxXG5ORm93RGpFTU1Bb0dBMVVFQXd3RGEyRnpNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDXG5BUUVBeE4zQVBpaFRpb2pjYUg2b1dqMXRNdFpNYWFaK0lBMXF0cUZtcHk1Rmc4RDViRXNQNzM2R3h6VU1Gc01WXG5zaHJLRVh6OGRZOUtwMjN1SXd5ZUMwUlBXTGU1eElmVGtKVWJ5THBxR2RsRWdxajEwUlE4a1NWcTI3MFhQRVMyXG5HWlVpajJEdUpWZndwVHBMemN0aTJQc2dFT29PS0M2Tm5uQUkwTlMxbWFvLzJEeFF4cy9EOWhBSmpHZHB6eW1iXG54aTJUeEdudllidm9mQ1BkOFJkRlRDUHZnd0tMUzcrTXFCY21pYzlWZFg5MVFOT1BtclAzcklvS3RqamQrNVBZXG5sL3o3M1BBeFIzSzNTSXpJWkx2SXRxMmFob2JPT01pU3h3OHNvT2xPZEhOVUpUcEVDY2R1aFJicXVxbUs2ZlR3XG5WT2ZyY1JRaGhVNFRrRHU5MkxJN1NnbE9XUUlEQVFBQm8xTXdVVEFkQmdOVkhRNEVGZ1FVZGd4eDdVNUFRZ2ZpXG5pUVd1M2toaTl5bmVFVm93SHdZRFZSMGpCQmd3Rm9BVWRneHg3VTVBUWdmaWlRV3Uza2hpOXluZUVWb3dEd1lEXG5WUjBUQVFIL0JBVXdBd0VCL3pBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQVFFQVRjTFliSG9tSmdMUS9INmlEdmNBXG5JcElTRi9SY3hnaDdObklxUmtCK1RtNHhObE5ISXhsNFN6K0trRVpFUGgwV0tJdEdWRGozMjkzckFyUk9FT1hJXG50Vm1uMk9CdjlNLzVEUWtIajc2UnU0UFEyVGNMMENBQ2wxSktmcVhMc01jNkhIVHA4WlRQOGxNZHBXNGt6RWMzXG5mVnRndnRwSmM0V0hkVUlFekF0VGx6WVJxSWJ5eUJNV2VUalh3YTU0YU12M1JaUWRKK0MwZWh3V1REUURwaDduXG5LWTMrN0cwZW5ORVZ0eVc0ZHR4dlFRYmlkTWFueTBKRXByNlFwUG14QzhlMFoyM2RNRGRrUjFJb1Q5OVBoZFcvXG5RQzh4TWp1TENpUkVWN2E2ZTJNeENHajNmeHJuTVh3T0lxTzNBek5zd2UyYW1jb3oya3R1b3FnRFRZbG8rRmtLXG41dz09XG4tLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tXG4=" --private-key-pem "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tXG5NSUlDL1RDQ0FlV2dBd0lCQWdJVVNIVEoyYnpBaDdkUW1tRjAzcTZJcS9uMGw5MHdEUVlKS29aSWh2Y05BUUVMXG5CUUF3RGpFTU1Bb0dBMVVFQXd3RGEyRnpNQjRYRFRJME1EWXdOakUzTkRZMU5Gb1hEVEkxTURZd05qRTNORFkxXG5ORm93RGpFTU1Bb0dBMVVFQXd3RGEyRnpNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDXG5BUUVBeE4zQVBpaFRpb2pjYUg2b1dqMXRNdFpNYWFaK0lBMXF0cUZtcHk1Rmc4RDViRXNQNzM2R3h6VU1Gc01WXG5zaHJLRVh6OGRZOUtwMjN1SXd5ZUMwUlBXTGU1eElmVGtKVWJ5THBxR2RsRWdxajEwUlE4a1NWcTI3MFhQRVMyXG5HWlVpajJEdUpWZndwVHBMemN0aTJQc2dFT29PS0M2Tm5uQUkwTlMxbWFvLzJEeFF4cy9EOWhBSmpHZHB6eW1iXG54aTJUeEdudllidm9mQ1BkOFJkRlRDUHZnd0tMUzcrTXFCY21pYzlWZFg5MVFOT1BtclAzcklvS3RqamQrNVBZXG5sL3o3M1BBeFIzSzNTSXpJWkx2SXRxMmFob2JPT01pU3h3OHNvT2xPZEhOVUpUcEVDY2R1aFJicXVxbUs2ZlR3XG5WT2ZyY1JRaGhVNFRrRHU5MkxJN1NnbE9XUUlEQVFBQm8xTXdVVEFkQmdOVkhRNEVGZ1FVZGd4eDdVNUFRZ2ZpXG5pUVd1M2toaTl5bmVFVm93SHdZRFZSMGpCQmd3Rm9BVWRneHg3VTVBUWdmaWlRV3Uza2hpOXluZUVWb3dEd1lEXG5WUjBUQVFIL0JBVXdBd0VCL3pBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQVFFQVRjTFliSG9tSmdMUS9INmlEdmNBXG5JcElTRi9SY3hnaDdObklxUmtCK1RtNHhObE5ISXhsNFN6K0trRVpFUGgwV0tJdEdWRGozMjkzckFyUk9FT1hJXG50Vm1uMk9CdjlNLzVEUWtIajc2UnU0UFEyVGNMMENBQ2wxSktmcVhMc01jNkhIVHA4WlRQOGxNZHBXNGt6RWMzXG5mVnRndnRwSmM0V0hkVUlFekF0VGx6WVJxSWJ5eUJNV2VUalh3YTU0YU12M1JaUWRKK0MwZWh3V1REUURwaDduXG5LWTMrN0cwZW5ORVZ0eVc0ZHR4dlFRYmlkTWFueTBKRXByNlFwUG14QzhlMFoyM2RNRGRrUjFJb1Q5OVBoZFcvXG5RQzh4TWp1TENpUkVWN2E2ZTJNeENHajNmeHJuTVh3T0lxTzNBek5zd2UyYW1jb3oya3R1b3FnRFRZbG8rRmtLXG41dz09XG4tLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tXG4=" --wrapping-key-id "openbao-key" --provider-config-id "f86b166a-98a5-407a-939f-ef84916ce1e5" +``` + +```shell +otdfctl policy kas-registry key create --key-id "aws-key" --algorithm "rsa:2048" --mode "remote" --kas "https://test-kas.com" --wrapping-key-id "openbao-key" --provider-config-id "f86b166a-98a5-407a-939f-ef84916ce1e5" --public-key-pem "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tXG5NSUlDL1RDQ0FlV2dBd0lCQWdJVVNIVEoyYnpBaDdkUW1tRjAzcTZJcS9uMGw5MHdEUVlKS29aSWh2Y05BUUVMXG5CUUF3RGpFTU1Bb0dBMVVFQXd3RGEyRnpNQjRYRFRJME1EWXdOakUzTkRZMU5Gb1hEVEkxTURZd05qRTNORFkxXG5ORm93RGpFTU1Bb0dBMVVFQXd3RGEyRnpNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDXG5BUUVBeE4zQVBpaFRpb2pjYUg2b1dqMXRNdFpNYWFaK0lBMXF0cUZtcHk1Rmc4RDViRXNQNzM2R3h6VU1Gc01WXG5zaHJLRVh6OGRZOUtwMjN1SXd5ZUMwUlBXTGU1eElmVGtKVWJ5THBxR2RsRWdxajEwUlE4a1NWcTI3MFhQRVMyXG5HWlVpajJEdUpWZndwVHBMemN0aTJQc2dFT29PS0M2Tm5uQUkwTlMxbWFvLzJEeFF4cy9EOWhBSmpHZHB6eW1iXG54aTJUeEdudllidm9mQ1BkOFJkRlRDUHZnd0tMUzcrTXFCY21pYzlWZFg5MVFOT1BtclAzcklvS3RqamQrNVBZXG5sL3o3M1BBeFIzSzNTSXpJWkx2SXRxMmFob2JPT01pU3h3OHNvT2xPZEhOVUpUcEVDY2R1aFJicXVxbUs2ZlR3XG5WT2ZyY1JRaGhVNFRrRHU5MkxJN1NnbE9XUUlEQVFBQm8xTXdVVEFkQmdOVkhRNEVGZ1FVZGd4eDdVNUFRZ2ZpXG5pUVd1M2toaTl5bmVFVm93SHdZRFZSMGpCQmd3Rm9BVWRneHg3VTVBUWdmaWlRV3Uza2hpOXluZUVWb3dEd1lEXG5WUjBUQVFIL0JBVXdBd0VCL3pBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQVFFQVRjTFliSG9tSmdMUS9INmlEdmNBXG5JcElTRi9SY3hnaDdObklxUmtCK1RtNHhObE5ISXhsNFN6K0trRVpFUGgwV0tJdEdWRGozMjkzckFyUk9FT1hJXG50Vm1uMk9CdjlNLzVEUWtIajc2UnU0UFEyVGNMMENBQ2wxSktmcVhMc01jNkhIVHA4WlRQOGxNZHBXNGt6RWMzXG5mVnRndnRwSmM0V0hkVUlFekF0VGx6WVJxSWJ5eUJNV2VUalh3YTU0YU12M1JaUWRKK0MwZWh3V1REUURwaDduXG5LWTMrN0cwZW5ORVZ0eVc0ZHR4dlFRYmlkTWFueTBKRXByNlFwUG14QzhlMFoyM2RNRGRrUjFJb1Q5OVBoZFcvXG5RQzh4TWp1TENpUkVWN2E2ZTJNeENHajNmeHJuTVh3T0lxTzNBek5zd2UyYW1jb3oya3R1b3FnRFRZbG8rRmtLXG41dz09XG4tLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tXG4=" +``` + +```shell +otdfctl policy kas-registry key create --key-id "aws-key" --algorithm "rsa:2048" --mode "public_key" --kas "https://test-kas.com" --public-key-pem "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tXG5NSUlDL1RDQ0FlV2dBd0lCQWdJVVNIVEoyYnpBaDdkUW1tRjAzcTZJcS9uMGw5MHdEUVlKS29aSWh2Y05BUUVMXG5CUUF3RGpFTU1Bb0dBMVVFQXd3RGEyRnpNQjRYRFRJME1EWXdOakUzTkRZMU5Gb1hEVEkxTURZd05qRTNORFkxXG5ORm93RGpFTU1Bb0dBMVVFQXd3RGEyRnpNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDXG5BUUVBeE4zQVBpaFRpb2pjYUg2b1dqMXRNdFpNYWFaK0lBMXF0cUZtcHk1Rmc4RDViRXNQNzM2R3h6VU1Gc01WXG5zaHJLRVh6OGRZOUtwMjN1SXd5ZUMwUlBXTGU1eElmVGtKVWJ5THBxR2RsRWdxajEwUlE4a1NWcTI3MFhQRVMyXG5HWlVpajJEdUpWZndwVHBMemN0aTJQc2dFT29PS0M2Tm5uQUkwTlMxbWFvLzJEeFF4cy9EOWhBSmpHZHB6eW1iXG54aTJUeEdudllidm9mQ1BkOFJkRlRDUHZnd0tMUzcrTXFCY21pYzlWZFg5MVFOT1BtclAzcklvS3RqamQrNVBZXG5sL3o3M1BBeFIzSzNTSXpJWkx2SXRxMmFob2JPT01pU3h3OHNvT2xPZEhOVUpUcEVDY2R1aFJicXVxbUs2ZlR3XG5WT2ZyY1JRaGhVNFRrRHU5MkxJN1NnbE9XUUlEQVFBQm8xTXdVVEFkQmdOVkhRNEVGZ1FVZGd4eDdVNUFRZ2ZpXG5pUVd1M2toaTl5bmVFVm93SHdZRFZSMGpCQmd3Rm9BVWRneHg3VTVBUWdmaWlRV3Uza2hpOXluZUVWb3dEd1lEXG5WUjBUQVFIL0JBVXdBd0VCL3pBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQVFFQVRjTFliSG9tSmdMUS9INmlEdmNBXG5JcElTRi9SY3hnaDdObklxUmtCK1RtNHhObE5ISXhsNFN6K0trRVpFUGgwV0tJdEdWRGozMjkzckFyUk9FT1hJXG50Vm1uMk9CdjlNLzVEUWtIajc2UnU0UFEyVGNMMENBQ2wxSktmcVhMc01jNkhIVHA4WlRQOGxNZHBXNGt6RWMzXG5mVnRndnRwSmM0V0hkVUlFekF0VGx6WVJxSWJ5eUJNV2VUalh3YTU0YU12M1JaUWRKK0MwZWh3V1REUURwaDduXG5LWTMrN0cwZW5ORVZ0eVc0ZHR4dlFRYmlkTWFueTBKRXByNlFwUG14QzhlMFoyM2RNRGRrUjFJb1Q5OVBoZFcvXG5RQzh4TWp1TENpUkVWN2E2ZTJNeENHajNmeHJuTVh3T0lxTzNBek5zd2UyYW1jb3oya3R1b3FnRFRZbG8rRmtLXG41dz09XG4tLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tXG4=" +``` + +1. The `"algorithm"` specifies the key algorithm: + + | Key Algorithm | + | -------------- | + | `rsa:2048` | + | `rsa:4096` | + | `ec:secp256r1` | + | `ec:secp384r1` | + | `ec:secp521r1` | + +2. The `"mode"` specifies where the key that is encrypting TDFs is stored. All keys will be encrypted when stored in Virtru's DB, for modes `"local"` and `"provider"` + + | Mode | Description | + | ------------ | ------------------------------------------------------------------------------------------------------- | + | `local` | Root Key is stored within Virtru's database and the symmetric wrapping key is stored in KAS | + | `provider` | Root Key is stored within Virtru's database and the symmetric wrapping key is stored externally | + | `remote` | Root Key and wrapping key are stored remotely | + | `public_key` | Root Key and wrapping key are stored remotely. Use this when importing another org's policy information | diff --git a/otdfctl/docs/man/policy/kas-registry/key/get.md b/otdfctl/docs/man/policy/kas-registry/key/get.md new file mode 100644 index 0000000000..fc25d06b2b --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/key/get.md @@ -0,0 +1,30 @@ +--- +title: Get Key +command: + name: get + aliases: + - g + flags: + - name: key + shorthand: k + description: The KeyID (human-readable identifier) or the internal UUID of the key to retrieve from the specified KAS. The system will attempt to resolve the provided value as either a UUID or a KeyID. + required: true + - name: kas + description: Specify the Key Access Server (KAS) where the key (identified by `--key`) is registered. The KAS can be identified by its ID, URI, or Name. + required: true + +--- + +This command retrieves detailed information about a specific key registered within a Key Access Server (KAS). You must specify the key using its KeyID or UUID and the KAS it belongs to. + +## Examples + +Retrieve details for a key identified by its UUID from a KAS specified by its URI: +``` +otdfctl policy kas-registry key get --key "123e4567-e89b-12d3-a456-426614174000" --kas "https://kas.example.com/kas" +``` + +Retrieve details for a key identified by its human-readable KeyID from a KAS specified by its name, and output in JSON format: +``` +otdfctl policy kas-registry key get --key "my-specific-key-v2" --kas "Secondary KAS" --json +``` diff --git a/otdfctl/docs/man/policy/kas-registry/key/import.md b/otdfctl/docs/man/policy/kas-registry/key/import.md new file mode 100644 index 0000000000..a75e4dd48b --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/key/import.md @@ -0,0 +1,79 @@ +--- +title: Import Key +command: + name: import + aliases: + - i + flags: + - name: key-id + description: A unique, often human-readable, identifier for the key being imported. + required: true + - name: algorithm + shorthand: a + description: Algorithm for the key being imported (see table below for options). + required: true + - name: kas + description: Specify the Key Access Server (KAS) where the key will be imported. The KAS can be identified by its ID, URI, or Name. + required: true + - name: wrapping-key-id + description: Identifier related to the wrapping key. + required: true + - name: wrapping-key + shorthand: w + description: The symmetric key material (AES cipher, hex encoded) used to wrap the imported private key. + required: true + - name: private-key-pem + description: The base64 encoded private key PEM to import + required: true + - name: public-key-pem + shorthand: e + description: The base64 encoded public key PEM to import + required: true + - name: legacy + description: Mark the imported key as a legacy key. + default: false + - name: label + shorthand: l + description: Comma-separated key=value pairs for metadata labels to associate with the imported key (e.g., "owner=team-a,env=production"). +--- + +Imports an existing cryptographic key into a specified Key Access Server (KAS). + +>[!IMPORTANT] +>Use this command when migrating keys from KAS over to the platform. +>All keys created with import will be of key_mode=**KEY_MODE_CONFIG_ROOT_KEY** + +## Examples + +### Import a key + +```shell +otdfctl policy kas-registry key import --key-id "imported-key" --algorithm "rsa:2048" \ + --kas 891cfe85-b381-4f85-9699-5f7dbfe2a9ab \ + --wrapping-key-id "my-wrapping-key" \ + --wrapping-key "a8c4824daafcfa38ed0d13002e92b08720e6c4fcee67d52e954c1a6e045907d1" \ + --public-key-pem \ + --private-key-pem \ +``` + +### Import a legacy key + +```shell +otdfctl policy kas-registry key import --key-id "imported-key" --algorithm "rsa:2048" \ + --kas 891cfe85-b381-4f85-9699-5f7dbfe2a9ab \ + --wrapping-key-id "my-wrapping-key" \ + --wrapping-key "a8c4824daafcfa38ed0d13002e92b08720e6c4fcee67d52e954c1a6e045907d1" \ + --public-key-pem \ + --private-key-pem \ + --legacy true +``` + +1. The `algorithm` specifies the key algorithm: + + | Key Algorithm | + | -------------- | + | `rsa:2048` | + | `rsa:4096` | + | `ec:secp256r1` | + | `ec:secp384r1` | + | `ec:secp521r1` | diff --git a/otdfctl/docs/man/policy/kas-registry/key/list-mappings.md b/otdfctl/docs/man/policy/kas-registry/key/list-mappings.md new file mode 100644 index 0000000000..4db131e1ab --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/key/list-mappings.md @@ -0,0 +1,49 @@ +--- +title: List Key Mappings +command: + name: list-mappings + aliases: + - m + flags: + - name: limit + shorthand: l + description: Maximum number of key mappings to return + required: true + - name: offset + shorthand: o + description: Offset (page) quantity from start of the list + required: true + - name: id + shorthand: i + description: The system ID of the key for which to list mappings. + - name: key-id + description: The user-defined ID of the key for which to list mappings. Must be used with --kas. + - name: kas + description: Specify the Key Access Server (KAS) where the key (identified by `--key-id`) is registered. The KAS can be identified by its ID, URI, or Name. +--- + +This command lists key mappings. You can list all key mappings, or filter by a specific key. + +To filter by a key, you can either provide the system ID of the key, or the user-defined key ID along with the KAS identifier. + +The list is paginated, so you must provide `limit` and `offset` flags. + +## Examples + +List the first 10 key mappings: + +```bash +otdfctl policy kas-registry key list-mappings --limit 10 --offset 0 +``` + +List key mappings for a key with a specific system ID: + +```bash +otdfctl policy kas-registry key list-mappings --id "cc8bf36a-8c76-4c8c-9723-3c0d1ce897b8" --limit 10 --offset 0 +``` + +List key mappings for a key with a user-defined ID within a KAS specified by its URI: + +```bash +otdfctl policy kas-registry key list-mappings --key-id "my-key" --kas "https://kas.example.com/kas" --limit 10 --offset 0 +``` diff --git a/otdfctl/docs/man/policy/kas-registry/key/list.md b/otdfctl/docs/man/policy/kas-registry/key/list.md new file mode 100644 index 0000000000..dcb34f1cae --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/key/list.md @@ -0,0 +1,60 @@ +--- +title: List Keys +command: + name: list + aliases: + - l + flags: + - name: limit + shorthand: l + description: Maximum number of keys to return + required: true + - name: offset + shorthand: o + description: Number of keys to skip before starting to return results + required: true + - name: algorithm + shorthand: a + description: Key Algorithm to filter for + - name: kas + description: Specify the Key Access Server (KAS) where the key (identified by `--key`) is registered. The KAS can be identified by its ID, URI, or Name. + - name: legacy + description: Filter keys by legacy status. + required: false +--- + +This command lists keys registered within a specified Key Access Server (KAS). You must specify the KAS using its ID, URI, or Name. + +The list can be filtered by key algorithm. Pagination is supported using `limit` and `offset` flags to manage the number of results returned. + +## Examples + +List the first 10 keys from a KAS specified by its URI: + +```shell +otdfctl policy kas-registry key list --kas "https://kas.example.com/kas" --limit 10 --offset 0 +``` + +List keys from a KAS named "Primary KAS", filtering for keys using the "RSA:2048" algorithm, and output in JSON format: + +```shell +otdfctl policy kas-registry key list --kas "Primary KAS" --alg "RSA:2048" --limit 20 --offset 0 --json +``` + +List the next 5 keys (skipping the first 5) from a KAS identified by its ID: + +```shell +otdfctl policy kas-registry key list --kas "kas-id-12345" --limit 5 --offset 5 +``` + +List only legacy keys + +```shell +otdfctl policy kas-registry key list --legacy true +``` + +Exclude legacy keys + +```shell +otdfctl policy kas-registry key list --legacy false +``` diff --git a/otdfctl/docs/man/policy/kas-registry/key/rotate.md b/otdfctl/docs/man/policy/kas-registry/key/rotate.md new file mode 100644 index 0000000000..860827e5eb --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/key/rotate.md @@ -0,0 +1,97 @@ +--- +title: Rotate Key +command: + name: rotate + aliases: + - r + flags: + # Flags for identifying the old key (from get.md) + - name: key + shorthand: k + description: The KeyID (human-readable identifier) or the internal UUID of the existing key to rotate from the specified KAS. The system will attempt to resolve the provided value as either a UUID or a KeyID. + required: true + - name: kas + description: Specify the Key Access Server (KAS) where the key is registered. The KAS can be identified by its ID, URI, or Name. + required: true + + # Flags for the new key creation (from create.md) + - name: key-id + description: A unique, often human-readable, identifier for the new key to be created. + required: true + - name: algorithm + shorthand: a + description: Algorithm for the new key (see table below for options). + required: true + - name: mode + shorthand: m + description: Describes how the private key is managed (see table below for options). + required: true + - name: wrapping-key-id + description: Identifier related to the wrapping key. Its meaning depends on the `mode`. For `local` mode, it's a descriptive ID for the `wrappingKey` you provide. For `provider` or `remote` mode, it's the ID of the key within the external provider/system used for wrapping. + - name: wrapping-key + shorthand: w + description: The symmetric key material (AES cipher, base64 encoded) used to wrap the generated private key. Primarily used when `mode` is `local`. + - name: private-key-pem + description: The private key PEM (encrypted by an AES 32-byte key, then base64 encoded). Used when importing an existing key pair, typically with `provider` mode. + - name: provider-config-id + shorthand: p + description: Configuration ID for the key provider. Often required when `mode` is `provider` or `remote` and an external key provider is used. + - name: public-key-pem + shorthand: e + description: The base64 encoded public key PEM. Required for `remote` and `public_key` modes, and can be used with `provider` mode if importing an existing key pair. + - name: label + shorthand: l + description: Comma-separated key=value pairs for metadata labels to associate with the new key (e.g., "owner=team-a,env=production"). +--- + +Rotates a cryptographic key within a specified Key Access Server (KAS). +This command replaces an existing key with a new one while maintaining references to the old key to ensure data encrypted with the old key can still be decrypted. + +## Examples + +### Rotate a key in `local` mode + +Rotate an existing key to a new key in local mode, where the KAS generates the key pair and the private key is wrapped by the provided `wrappingKey`: + +```shell +otdfctl policy kas-registry key rotate --key "old-key-id" --kas "https://kas.example.com/kas" --key-id "new-key-v2" --algorithm "rsa:2048" --mode "local" --wrapping-key-id "virtru-stored-key" --wrapping-key "YWVzIGtleQ==" +``` + +### Rotate a key in `provider` mode + +```shell +otdfctl policy kas-registry key rotate --key "123e4567-e89b-12d3-a456-426614174000" --kas "https://kas.example.com/kas" --key-id "provider-key-v2" --algorithm "rsa:2048" --mode "provider" --public-key-pem "LS0tLS1CRUdJTi..." --private-key-pem "LS0tLS1CRUdJTi..." --wrapping-key-id "openbao-key" --provider-config-id "f86b166a-98a5-407a-939f-ef84916ce1e5" +``` + +### Rotate a key in `remote` mode + +```shell +otdfctl policy kas-registry key rotate --key "my-remote-key" --kas "Secondary KAS" --key-id "remote-key-v2" --algorithm "rsa:2048" --mode "remote" --wrapping-key-id "openbao-key" --provider-config-id "f86b166a-98a5-407a-939f-ef84916ce1e5" --public-key-pem "LS0tLS1CRUdJTi..." +``` + +### Rotate a key in `public_key` mode + +```shell +otdfctl policy kas-registry key rotate --key "public-key-old" --kas "Secondary KAS" --key-id "public-key-v2" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "LS0tLS1CRUdJTi..." +``` + +## Key Algorithms and Modes + +1. The `"algorithm"` specifies the key algorithm: + + | Key Algorithm | + | -------------- | + | `rsa:2048` | + | `rsa:4096` | + | `ec:secp256r1` | + | `ec:secp384r1` | + | `ec:secp521r1` | + +2. The `"mode"` specifies where the key that is encrypting TDFs is stored. All keys will be encrypted when stored in Virtru's DB, for modes `"local"` and `"provider"` + + | Mode | Description | + | ------------ | ------------------------------------------------------------------------------------------------------- | + | `local` | Root Key is stored within Virtru's database and the symmetric wrapping key is stored in KAS | + | `provider` | Root Key is stored within Virtru's database and the symmetric wrapping key is stored externally | + | `remote` | Root Key and wrapping key are stored remotely | + | `public_key` | Root Key and wrapping key are stored remotely. Use this when importing another org's policy information | diff --git a/otdfctl/docs/man/policy/kas-registry/key/unsafe/_index.md b/otdfctl/docs/man/policy/kas-registry/key/unsafe/_index.md new file mode 100644 index 0000000000..26155272ba --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/key/unsafe/_index.md @@ -0,0 +1,17 @@ +--- +title: Unsafe changes to keys +command: + name: unsafe + flags: + - name: force + description: Force unsafe change without confirmation + required: false +--- + +Unsafe changes are dangerous mutations to KAS that can significantly change access behavior around existing keys +and entitlement. + +Depending on the unsafe change introduced and already existing TDFs, TDFs might become inaccessible that were previously +accessible or vice versa. + +Make sure you know what you are doing. \ No newline at end of file diff --git a/otdfctl/docs/man/policy/kas-registry/key/unsafe/delete.md b/otdfctl/docs/man/policy/kas-registry/key/unsafe/delete.md new file mode 100644 index 0000000000..0aa8828a0c --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/key/unsafe/delete.md @@ -0,0 +1,28 @@ +--- +title: Delete a key +command: + name: delete + flags: + - name: id + shorthand: i + description: Sytem given ID of the key + required: true + - name: kas-uri + description: The URI of the KAS instance + required: true + - name: key-id + description: The ID of the key assigned by the admin + required: true +--- + +# Unsafe Delete Warning + +Deleting a key is a destructive operation. Any existing TDFs encrypted with this key will be rendered inaccessible. + +Make sure you know what you are doing. + +## Example + +```shell +otdfctl policy kas-keys unsafe delete --id 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --kas-uri https://kas.example.com --key-id "key-1" +``` diff --git a/otdfctl/docs/man/policy/kas-registry/key/update.md b/otdfctl/docs/man/policy/kas-registry/key/update.md new file mode 100644 index 0000000000..02946f9d01 --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/key/update.md @@ -0,0 +1,26 @@ +--- +title: Update Key Access Server Key +command: + name: update + aliases: + - u + flags: + - name: id + shorthand: i + description: The internal UUID of the key to be updated. + required: true + - name: label + shorthand: l + description: Comma-separated key=value pairs for metadata labels (e.g., "owner=team-a,env=production"). Providing new labels will replace any existing labels on the key. +--- + +This command updates the key for an existing key registered in a Key Access Server (KAS). +You must identify the key using its UUID via the `--id` flag. +Currently, this command primarily supports updating the metadata labels associated with the key. + +## Examples + +Update key identified by its UUID: +``` +otdfctl policy kas-registry key update --id "123e4567-e89b-12d3-a456-426614174000" --label "status=active,project=phoenix" +``` diff --git a/otdfctl/docs/man/policy/kas-registry/list.md b/otdfctl/docs/man/policy/kas-registry/list.md new file mode 100644 index 0000000000..6eb8581b7a --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/list.md @@ -0,0 +1,22 @@ +--- +title: List Key Access Server registrations +command: + name: list + aliases: + - l + flags: + - name: limit + shorthand: l + description: Limit retrieved count + - name: offset + shorthand: o + description: Offset (page) quantity from start of the list +--- + +For more information about registration of Key Access Servers, see the manual for `kas-registry`. + +## Example + +```shell +otdfctl policy kas-registry list +``` diff --git a/otdfctl/docs/man/policy/kas-registry/update.md b/otdfctl/docs/man/policy/kas-registry/update.md new file mode 100644 index 0000000000..a95ad29c1c --- /dev/null +++ b/otdfctl/docs/man/policy/kas-registry/update.md @@ -0,0 +1,47 @@ +--- +title: Update a Key Access Server registration +command: + name: update + aliases: + - u + flags: + - name: id + shorthand: i + description: ID of the Key Access Server registration + required: true + - name: uri + shorthand: u + description: URI of the Key Access Server + - name: public-keys + shorthand: c + description: One or more 'cached' public keys saved for the KAS + - name: public-key-remote + shorthand: r + description: URI of the 'remote' public key of the Key Access Server + - name: name + shorthand: n + description: Optional name of the registered KAS (must be unique within Policy) + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' + - name: force-replace-labels + description: Destructively replace entire set of existing metadata 'labels' with any provided to this command + default: false +--- + +Update the `uri`, `metadata`, or key material (remote/cached) for a KAS registered to the platform. + +If resource data has been TDFd utilizing key splits from the registered KAS, deletion from +the registry (and therefore any associated grants) may prevent decryption depending on the +type of grants and relevant key splits. + +Make sure you know what you are doing. + +For more information about registration of Key Access Servers, see the manual for `kas-registry`. + +## Example + +```shell +otdfctl policy kas-registry update --id 3c39618a-cd8c-48cf-a60c-e8a2f4be4dd5 --name example-kas2-newname --public-key-remote "https://example.com/kas2/new_public_key" +``` diff --git a/otdfctl/docs/man/policy/key-management/_index.md b/otdfctl/docs/man/policy/key-management/_index.md new file mode 100644 index 0000000000..53f35eb456 --- /dev/null +++ b/otdfctl/docs/man/policy/key-management/_index.md @@ -0,0 +1,14 @@ +--- +title: Key management + +command: + name: keymanagement + aliases: + - k + flags: + - name: json + description: output single command in JSON (overrides configured output format) + default: 'false' +--- + +Set of commands for managing key configuration, currently supports managing key provider configuration via the `provider` command. diff --git a/otdfctl/docs/man/policy/key-management/provider/_index.md b/otdfctl/docs/man/policy/key-management/provider/_index.md new file mode 100644 index 0000000000..25e11c02cf --- /dev/null +++ b/otdfctl/docs/man/policy/key-management/provider/_index.md @@ -0,0 +1,18 @@ +--- +title: Provider configuration for Key Management + +command: + name: provider + aliases: + - p +--- + +Commands used for managing a key providers configuration. You should register key providers when creating keys where the key is either: + +1. Wrapped by a key stored outside of your KAS server. For example. if you created a key that is of `mode``provider` +2. The actual wrapped key is not stored within the platform database, but a reference to the key is. For example, if you created a key that is of `mode` `remote`. + +**You should not** create provider configurations for keys of mode: + +- `local` +- `public_key` diff --git a/otdfctl/docs/man/policy/key-management/provider/create.md b/otdfctl/docs/man/policy/key-management/provider/create.md new file mode 100644 index 0000000000..076e73969d --- /dev/null +++ b/otdfctl/docs/man/policy/key-management/provider/create.md @@ -0,0 +1,35 @@ +--- +title: Create a Provider Config +command: + name: create + aliases: + - c + flags: + - name: name + shorthand: n + description: Name of the provider config to create + required: true + - name: manager + shorthand: m + description: Key Manager for the provider config + required: true + - name: config + shorthand: c + description: JSON configuration for the provider + required: true + - name: label + shorthand: l + description: Metadata labels for the provider config +--- + +Creates a new provider config with the specified name and configuration. + +## Examples + +```shell +otdfctl keymanagement provider create --name --config +``` + +```shell +otdfctl keymanagement provider create --name aws --config `{"region": "us-west-2"}` +``` diff --git a/otdfctl/docs/man/policy/key-management/provider/delete.md b/otdfctl/docs/man/policy/key-management/provider/delete.md new file mode 100644 index 0000000000..a9491e9726 --- /dev/null +++ b/otdfctl/docs/man/policy/key-management/provider/delete.md @@ -0,0 +1,32 @@ +--- +title: Delete a Provider Config +command: + name: delete + aliases: + - d + - remove + flags: + - name: force + shorthand: f + description: Force the deletion of a provider configuration without confirmation + - name: id + shorthand: i + description: ID of the provider config to delete + required: true +--- + +Deletes a provider config by its unique ID. + +## Examples + +```shell +otdfctl keymanagement provider delete --id +``` + +```shell +otdfctl keymanagement provider delete --id '04ba179c-2f77-4e0d-90c5-fe4d1c9aa3f7' +``` + +```shell +otdfctl keymanagement provider delete --id '04ba179c-2f77-4e0d-90c5-fe4d1c9aa3f7' --force +``` diff --git a/otdfctl/docs/man/policy/key-management/provider/get.md b/otdfctl/docs/man/policy/key-management/provider/get.md new file mode 100644 index 0000000000..231d0ad0b8 --- /dev/null +++ b/otdfctl/docs/man/policy/key-management/provider/get.md @@ -0,0 +1,22 @@ +--- +title: Get a Provider Config +command: + name: get + aliases: + - g + flags: + - name: id + shorthand: i + description: ID of the provider config to retrieve + - name: name + shorthand: n + description: Name of the provider config to retrieve +--- + +Retrieves a provider config by its ID or name. + +## Examples + +```shell +otdfctl keymanagement provider get --id '04ba179c-2f77-4e0d-90c5-fe4d1c9aa3f7' +``` diff --git a/otdfctl/docs/man/policy/key-management/provider/list.md b/otdfctl/docs/man/policy/key-management/provider/list.md new file mode 100644 index 0000000000..a408260246 --- /dev/null +++ b/otdfctl/docs/man/policy/key-management/provider/list.md @@ -0,0 +1,24 @@ +--- +title: List Provider Configs +command: + name: list + aliases: + - l + flags: + - name: limit + shorthand: l + description: Maximum number of results to return + required: true + - name: offset + shorthand: o + description: Offset for pagination + required: true +--- + +Lists all provider configs with pagination support. + +## Examples + +```shell +otdfctl keymanagement provider list --limit 10 --offset 0 +``` diff --git a/otdfctl/docs/man/policy/key-management/provider/update.md b/otdfctl/docs/man/policy/key-management/provider/update.md new file mode 100644 index 0000000000..c11d4344d2 --- /dev/null +++ b/otdfctl/docs/man/policy/key-management/provider/update.md @@ -0,0 +1,36 @@ +--- +title: Update a Provider Config +command: + name: update + aliases: + - u + flags: + - name: id + shorthand: i + description: ID of the provider config to update + required: true + - name: name + shorthand: n + description: New name for the provider config + - name: manager + shorthand: m + description: New key manager for the provider config + - name: config + shorthand: c + description: New JSON configuration for the provider + - name: label + shorthand: l + description: Metadata labels for the provider config +--- + +Updates an existing provider config with the specified parameters. + +## Examples + +```shell +otdfctl keymanagement provider update --id --name --config +``` + +```shell +otdfctl keymanagement provider update --id '04ba179c-2f77-4e0d-90c5-fe4d1c9aa3f7' --name 'gcp' --config `{"region": "us-west-2"}` +``` diff --git a/otdfctl/docs/man/policy/namespaces/_index.md b/otdfctl/docs/man/policy/namespaces/_index.md new file mode 100644 index 0000000000..26d17751b1 --- /dev/null +++ b/otdfctl/docs/man/policy/namespaces/_index.md @@ -0,0 +1,18 @@ +--- +title: Manage attribute namespaces +command: + name: namespaces + aliases: + - ns + - namespace +--- + +A namespace is the root (parent) of a set of platform policy. Like an owner or an authority, it fully qualifies attributes and their values, +resource mapping groups, etc. As the various mappings of a platform are to attributes or values, a namespace effectively "owns" the +mappings as well (transitively if not directly). + +In an attribute or other FQN (Fully Qualified Name), the namespace is found after the scheme: `https://` + +Namespaces, like other FQN'd objects, are normalized to lower case both on create and in a decision request lookup. + +As the Namespace is the parent of policy, a namespace's existence is required to create attributes or resource mapping groups beneath. diff --git a/otdfctl/docs/man/policy/namespaces/create.md b/otdfctl/docs/man/policy/namespaces/create.md new file mode 100644 index 0000000000..7fbd064b72 --- /dev/null +++ b/otdfctl/docs/man/policy/namespaces/create.md @@ -0,0 +1,31 @@ +--- +title: Create an attribute namespace +command: + name: create + aliases: + - c + - add + - new + flags: + - name: name + shorthand: n + description: Name of the attribute namespace (must be unique within Policy) + required: true + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' +--- + +Creation of a `namespace` is required to add attributes or any other policy objects beneath. + +A namespace `name` is normalized to lower case, may contain hyphens and underscores between other alphanumeric characters, and it +must contain two segments separated by a `.`, such as `example.com`. + +For more information, see the `namespaces` subcommand. + +## Example + +```shell +otdfctl policy namespaces create --name opentdf.io +``` diff --git a/otdfctl/docs/man/policy/namespaces/deactivate.md b/otdfctl/docs/man/policy/namespaces/deactivate.md new file mode 100644 index 0000000000..35de01d68e --- /dev/null +++ b/otdfctl/docs/man/policy/namespaces/deactivate.md @@ -0,0 +1,29 @@ +--- +title: Deactivate an attribute namespace +command: + name: deactivate + flags: + - name: id + shorthand: i + description: ID of the attribute namespace + required: true + - name: force + description: Force deactivation without interactive confirmation (dangerous) +--- + +Deactivating an Attribute Namespace will make the namespace name inactive as well as any attribute definitions and values beneath. + +Deactivation of a Namespace renders any existing TDFs of those attributes inaccessible. + +Deactivation will permanently reserve the Namespace name within a platform. Reactivation and deletion are both considered "unsafe" +behaviors. + +For information about reactivation, see the `unsafe reactivate` subcommand. + +For reactivation, see the `unsafe` command. + +## Example + +```shell +otdfctl policy namespaces deactivate --id 7650f02a-be00-4faa-a1d1-37cded5e23dc +``` diff --git a/otdfctl/docs/man/policy/namespaces/get.md b/otdfctl/docs/man/policy/namespaces/get.md new file mode 100644 index 0000000000..3bac5f2753 --- /dev/null +++ b/otdfctl/docs/man/policy/namespaces/get.md @@ -0,0 +1,19 @@ +--- +title: Get an attribute namespace +command: + name: get + aliases: + - g + flags: + - name: id + shorthand: i + description: ID of the attribute namespace +--- + +For more information, see the `namespaces` subcommand. + +## Example + +```shell +otdfctl policy namespaces get --id=7650f02a-be00-4faa-a1d1-37cded5e23dc +``` \ No newline at end of file diff --git a/otdfctl/docs/man/policy/namespaces/key/_index.md b/otdfctl/docs/man/policy/namespaces/key/_index.md new file mode 100644 index 0000000000..39b82e9b05 --- /dev/null +++ b/otdfctl/docs/man/policy/namespaces/key/_index.md @@ -0,0 +1,7 @@ +--- +title: Key Management changes to attribute namespaces +command: + name: key +--- + +Manages KAS key associations for attribute namespaces. diff --git a/otdfctl/docs/man/policy/namespaces/key/assign.md b/otdfctl/docs/man/policy/namespaces/key/assign.md new file mode 100644 index 0000000000..489de5d88f --- /dev/null +++ b/otdfctl/docs/man/policy/namespaces/key/assign.md @@ -0,0 +1,26 @@ +--- +title: Assign a KAS key to an attribute namespace +command: + name: assign + flags: + - name: namespace + shorthand: n + description: Can be URI or ID of namespace + required: true + - name: key-id + shorthand: k + description: ID of the KAS key to assign + required: true +--- + +Assigns a KAS key to a policy attribute namespace. This enables the attribute namespace to be used with the specified KAS key for encryption and decryption operations. + +## Example + +```shell +otdfctl policy namespaces assign --namespace 3d25d33e-2469-4990-a9ed-fdd13ce74436 --key-id 8f7e6d5c-4b3a-2d1e-9f8d-7c6b5a432f1d +``` + +```shell +otdfctl policy namespaces remove --namespace "https://example.com" --key-id 8f7e6d5c-4b3a-2d1e-9f8d-7c6b5a432f1d +``` diff --git a/otdfctl/docs/man/policy/namespaces/key/remove.md b/otdfctl/docs/man/policy/namespaces/key/remove.md new file mode 100644 index 0000000000..6853dc2335 --- /dev/null +++ b/otdfctl/docs/man/policy/namespaces/key/remove.md @@ -0,0 +1,26 @@ +--- +title: Remove a KAS key from an attribute namespace +command: + name: remove + flags: + - name: namespace + shorthand: n + description: Can be URI or ID of namespace + required: true + - name: key-id + shorthand: k + description: ID of the KAS key to remove + required: true +--- + +Removes a KAS key from a policy attribute namespace. After removing the key, the attribute namespace can no longer be used with the specified KAS key for encryption and decryption operations. + +## Example + +```shell +otdfctl policy namespaces remove --namespace 3d25d33e-2469-4990-a9ed-fdd13ce74436 --key-id 8f7e6d5c-4b3a-2d1e-9f8d-7c6b5a432f1d +``` + +```shell +otdfctl policy namespaces remove --namespace "https://example.com" --key-id 8f7e6d5c-4b3a-2d1e-9f8d-7c6b5a432f1d +``` diff --git a/otdfctl/docs/man/policy/namespaces/list.md b/otdfctl/docs/man/policy/namespaces/list.md new file mode 100644 index 0000000000..390d84a5f8 --- /dev/null +++ b/otdfctl/docs/man/policy/namespaces/list.md @@ -0,0 +1,26 @@ +--- +title: List attribute namespaces +command: + name: list + aliases: + - ls + - l + flags: + - name: state + shorthand: s + description: Filter by state [active, inactive, any] + - name: limit + shorthand: l + description: Limit retrieved count + - name: offset + shorthand: o + description: Offset (page) quantity from start of the list +--- + +For more general information, see the `namespaces` subcommand. + +## Example + +```shell +otdfctl policy namespaces list +``` diff --git a/otdfctl/docs/man/policy/namespaces/unsafe/_index.md b/otdfctl/docs/man/policy/namespaces/unsafe/_index.md new file mode 100644 index 0000000000..b86cc14498 --- /dev/null +++ b/otdfctl/docs/man/policy/namespaces/unsafe/_index.md @@ -0,0 +1,19 @@ +--- +title: Unsafe changes to attribute namespaces +command: + name: unsafe + flags: + - name: force + description: Force unsafe change without confirmation + required: false +--- + +Unsafe changes are dangerous mutations to Policy that can significantly change access behavior around existing attributes +and entitlement. + +Depending on the unsafe change introduced and already existing TDFs, TDFs might become inaccessible that were previously +accessible or vice versa. + +Make sure you know what you are doing. + +For more general information, see the `namespaces` subcommand. diff --git a/otdfctl/docs/man/policy/namespaces/unsafe/delete.md b/otdfctl/docs/man/policy/namespaces/unsafe/delete.md new file mode 100644 index 0000000000..74ef450328 --- /dev/null +++ b/otdfctl/docs/man/policy/namespaces/unsafe/delete.md @@ -0,0 +1,26 @@ +--- +title: Delete an attribute namespace +command: + name: delete + flags: + - name: id + shorthand: i + description: ID of the attribute namespace + required: true +--- + +# Unsafe Delete Warning + +Deleting a Namespace cascades deletion of any Attribute Definitions, Values, and any associated mappings underneath. + +Any existing TDFs containing attributes under this namespace will be rendered inaccessible until it has been recreated. + +Make sure you know what you are doing. + +For more general information, see the `namespaces` subcommand. + +## Example + +```shell +otdfctl policy namespaces unsafe delete --id 7650f02a-be00-4faa-a1d1-37cded5e23dc +``` diff --git a/otdfctl/docs/man/policy/namespaces/unsafe/reactivate.md b/otdfctl/docs/man/policy/namespaces/unsafe/reactivate.md new file mode 100644 index 0000000000..2f4a6b973f --- /dev/null +++ b/otdfctl/docs/man/policy/namespaces/unsafe/reactivate.md @@ -0,0 +1,26 @@ +--- +title: Reactivate an attribute namespace +command: + name: reactivate + flags: + - name: id + shorthand: i + description: ID of the attribute namespace + required: true +--- + +# Unsafe Reactivate Warning + +Reactivating a Namespace can potentially open up an access path to any existing TDFs referencing attributes under that Namespace. + +The Active/Inactive state of any Attribute Definitions or Values under this Namespace will NOT be changed. + +Make sure you know what you are doing. + +For more general information, see the `namespaces` subcommand. + +## Example + +```shell +otdfctl policy namespaces unsafe reactivate --id 7650f02a-be00-4faa-a1d1-37cded5e23dc +``` diff --git a/otdfctl/docs/man/policy/namespaces/unsafe/update.md b/otdfctl/docs/man/policy/namespaces/unsafe/update.md new file mode 100644 index 0000000000..a2ead5a776 --- /dev/null +++ b/otdfctl/docs/man/policy/namespaces/unsafe/update.md @@ -0,0 +1,31 @@ +--- +title: Update an attribute namespace +command: + name: update + flags: + - name: id + shorthand: i + description: ID of the attribute namespace + required: true + - name: name + shorthand: n + description: Name of the attribute namespace (new) + required: true +--- + +# Unsafe Update Warning + +Renaming a Namespace means any Attribute Definitions, Values, and any associated mappings underneath will now be tied to the new name. + +Any existing TDFs containing attributes under the old namespace will be rendered inaccessible, and any TDFs tied to the new namespace +and already created may now become accessible. + +Make sure you know what you are doing. + +For more general information, see the `namespaces` subcommand. + +## Example + +```shell +otdfctl policy namespaces unsafe update --id=7650f02a-be00-4faa-a1d1-37cded5e23dc --name opentdf2.io +``` diff --git a/otdfctl/docs/man/policy/namespaces/update.md b/otdfctl/docs/man/policy/namespaces/update.md new file mode 100644 index 0000000000..25cba980d1 --- /dev/null +++ b/otdfctl/docs/man/policy/namespaces/update.md @@ -0,0 +1,29 @@ +--- +title: Update a attribute namespace +command: + name: update + aliases: + - u + flags: + - name: id + shorthand: i + description: ID of the attribute namespace + required: true + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' + - name: force-replace-labels + description: Destructively replace entire set of existing metadata 'labels' with any provided to this command + default: false +--- + +Attribute Namespace changes can be dangerous, so this command is for updates considered "safe" (currently just mutations to metadata `labels`). + +For unsafe updates, see the dedicated `unsafe update` command. For more general information, see the `namespaces` subcommand. + +## Example + +```shell +otdfctl policy namespaces update --id=7650f02a-be00-4faa-a1d1-37cded5e23dc --label hello=world +``` diff --git a/otdfctl/docs/man/policy/obligations/_index.md b/otdfctl/docs/man/policy/obligations/_index.md new file mode 100644 index 0000000000..609636cf1c --- /dev/null +++ b/otdfctl/docs/man/policy/obligations/_index.md @@ -0,0 +1,9 @@ +--- +title: Manage obligations +command: + name: obligations + aliases: + - obl +--- + +Obligations enable conditional access enforcement at the Policy Enforcement Point (PEP) level and allow security administrators to enforce additional restrictions beyond basic attribute-based access control (ABAC), such as requiring multi-factor authentication (MFA), enforcing watermarking, or applying time-based access expiration. diff --git a/otdfctl/docs/man/policy/obligations/create.md b/otdfctl/docs/man/policy/obligations/create.md new file mode 100644 index 0000000000..4f23e712a5 --- /dev/null +++ b/otdfctl/docs/man/policy/obligations/create.md @@ -0,0 +1,37 @@ +--- +title: Create an obligation definition +command: + name: create + aliases: + - c + - add + - new + flags: + - name: name + shorthand: n + description: Name of the obligation (must be unique within a Namespace) + required: true + - name: namespace + shorthand: s + description: Namespace ID or FQN + required: true + - name: value + shorthand: v + description: Value of the obligation (i.e. 'value1', must be unique within the Obligation) + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' +--- + +Add an obligation definition to the platform Policy. + +For more information, see the `obligations` subcommand. + +## Examples + +Create an obligation definition named 'my_obligation' with value 'my_value': + +```shell +otdfctl policy obligations create --name my_obligation --value my_value +``` diff --git a/otdfctl/docs/man/policy/obligations/delete.md b/otdfctl/docs/man/policy/obligations/delete.md new file mode 100644 index 0000000000..605b9559e1 --- /dev/null +++ b/otdfctl/docs/man/policy/obligations/delete.md @@ -0,0 +1,34 @@ +--- +title: Delete an obligation definition +command: + name: delete + flags: + - name: id + shorthand: i + description: ID of the obligation + - name: fqn + shorthand: f + description: FQN of the obligation + - name: force + description: Force deletion without interactive confirmation +--- + +Removes an obligation definition from platform Policy. + +Obligation deletion cascades to the associated obligation values. + +For more information about obligations, see the manual for the `obligations` subcommand. + +## Example + +Delete by ID: + +```shell +otdfctl policy obligations delete --id 217b300a-47f9-4bee-be8c-d38c880053f7 +``` + +Delete by FQN: + +```shell +otdfctl policy obligations delete --fqn "https://namespace.com/obl/name/drm" +``` \ No newline at end of file diff --git a/otdfctl/docs/man/policy/obligations/get.md b/otdfctl/docs/man/policy/obligations/get.md new file mode 100644 index 0000000000..7c2320fa97 --- /dev/null +++ b/otdfctl/docs/man/policy/obligations/get.md @@ -0,0 +1,34 @@ +--- +title: Get an obligation definition +command: + name: get + aliases: + - g + flags: + - name: id + shorthand: i + description: ID of the obligation + - name: fqn + shorthand: f + description: FQN of the obligation +--- + +Retrieve an obligation definition along with its metadata and values. + +If both `id` and `fqn` flag values are provided, `id` is preferred. + +For more information about obligations, see the manual for the `obligations` subcommand. + +## Example + +Get by ID: + +```shell +otdfctl policy obligations get --id=3c51a593-cbf8-419d-b7dc-b656d0bedfbb +``` + +Get by FQN: + +```shell +otdfctl policy obligations get --fqn=https://namespace.com/obl/drm +``` diff --git a/otdfctl/docs/man/policy/obligations/list.md b/otdfctl/docs/man/policy/obligations/list.md new file mode 100644 index 0000000000..f497413140 --- /dev/null +++ b/otdfctl/docs/man/policy/obligations/list.md @@ -0,0 +1,27 @@ +--- +title: List obligation definitions +command: + name: list + aliases: + - l + flags: + - name: limit + shorthand: l + description: Limit retrieved count + - name: offset + shorthand: o + description: Offset (page) quantity from start of the list + - name: namespace + shorthand: n + description: Namespace ID or FQN by which to filter results +--- + +List obligations definitions (optionally by namespace). + +For more information about obligations, see the `obligations` subcommand. + +## Example + +```shell +otdfctl policy obligations list --limit 10 --offset 0 +``` diff --git a/otdfctl/docs/man/policy/obligations/triggers/_index.md b/otdfctl/docs/man/policy/obligations/triggers/_index.md new file mode 100644 index 0000000000..0f98080299 --- /dev/null +++ b/otdfctl/docs/man/policy/obligations/triggers/_index.md @@ -0,0 +1,7 @@ +--- +title: Manage obligation triggers +command: + name: triggers +--- + +Obligations triggers are the link between an attribute value, a PEPs intended action on a TDF, and what the PEP is obliged to do. diff --git a/otdfctl/docs/man/policy/obligations/triggers/create.md b/otdfctl/docs/man/policy/obligations/triggers/create.md new file mode 100644 index 0000000000..8b1d95d88c --- /dev/null +++ b/otdfctl/docs/man/policy/obligations/triggers/create.md @@ -0,0 +1,56 @@ +--- +title: Create an obligation trigger +command: + name: create + aliases: + - c + - add + - new + flags: + - name: attribute-value + description: Attribute value ID or FQN + required: true + - name: action + description: Action ID or Name + required: true + - name: obligation-value + description: Obligation value ID or FQN + required: true + - name: client-id + description: Create a scoped trigger. Optionally include the clientID for which this trigger should be scoped to. + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l +--- + +Add an obligation trigger to the platform Policy with our without a client identifier. + +>[!NOTE] +>Creating an obligation trigger with a client-id scopes the +>trigger to a specific policy enforcement point, which is identified +>through the requestor's authentication token. +>Scoping a trigger to a specific client does two things: +> +>1. If the requesting application is **NOT** scoped to the trigger, it will not be used in the authorization decisioning and the obligation does not need to be fulfilled. +>2. If the requesting application **IS** scoped to the trigger, the application must +>be able to fulfill the obligation the trigger is mapped to. + +## Examples + +Create an obligation trigger with FQNs/Names: + +```shell +otdfctl policy obligations triggers create --attribute-value "https://example.com/attr/classification/value/confidential" --action "read" --obligation-value "https://example.com/obl/test/value/mfa" +``` + +Create an obligation trigger with IDs + +```shell +otdfctl policy obligations triggers create --attribute-value "d10e0fb6-4b4a-4976-8036-33903ebc6be3" --action "f15f65db-6889-453a-b032-212f78e8eb18" --obligation-value "0cbbb9bb-ed2d-41c0-8efa-1bcdddc44771" +``` + +Create a scoped obligation trigger with IDs. + +```shell +otdfctl policy obligations triggers create --attribute-value "d10e0fb6-4b4a-4976-8036-33903ebc6be3" --action "f15f65db-6889-453a-b032-212f78e8eb18" --obligation-value "0cbbb9bb-ed2d-41c0-8efa-1bcdddc44771" --client-id "my-service" +``` diff --git a/otdfctl/docs/man/policy/obligations/triggers/delete.md b/otdfctl/docs/man/policy/obligations/triggers/delete.md new file mode 100644 index 0000000000..49581d4dd4 --- /dev/null +++ b/otdfctl/docs/man/policy/obligations/triggers/delete.md @@ -0,0 +1,27 @@ +--- +title: Delete an obligation trigger +command: + name: delete + flags: + - name: id + description: ID of the obligation trigger to delete + required: true + - name: force + description: Force deletion without interactive confirmation +--- + +Delete an obligation trigger. + +## Examples + +Delete an obligation trigger by its ID: + +```shell +otdfctl policy obligations triggers delete --id "79b798f2-50a4-4a6d-9c5d-0f0e3c8787e8" +``` + +Force the deletion of an obligation trigger: + +```shell +otdfctl policy obligations triggers delete --id "79b798f2-50a4-4a6d-9c5d-0f0e3c8787e8" --force +``` diff --git a/otdfctl/docs/man/policy/obligations/triggers/list.md b/otdfctl/docs/man/policy/obligations/triggers/list.md new file mode 100644 index 0000000000..377820f886 --- /dev/null +++ b/otdfctl/docs/man/policy/obligations/triggers/list.md @@ -0,0 +1,29 @@ +--- +title: List obligation triggers +command: + name: list + aliases: + - l + flags: + - name: limit + shorthand: l + description: Limit retrieved count + - name: offset + shorthand: o + description: Offset (page) quantity from start of the list + - name: namespace + shorthand: n + description: Namespace ID or FQN by which to filter results +--- + +List obligation triggers (optionally by namespace). + +## Example + +```shell +otdfctl policy obligations triggers list --limit 10 --offset 0 +``` + +```shell +otdfctl policy obligations triggers list --limit 10 --offset 0 --namespace "https://example.com" +``` diff --git a/otdfctl/docs/man/policy/obligations/update.md b/otdfctl/docs/man/policy/obligations/update.md new file mode 100644 index 0000000000..4db33b31fe --- /dev/null +++ b/otdfctl/docs/man/policy/obligations/update.md @@ -0,0 +1,36 @@ +--- +title: Update an obligation definition +command: + name: update + aliases: + - u + flags: + - name: id + shorthand: i + description: ID of the obligation to update + required: true + - name: name + shorthand: n + description: Optional updated name of the obligation (must be unique within the Namespace) + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' + - name: force-replace-labels + description: Destructively replace entire set of existing metadata 'labels' with any provided to this command + default: false +--- + +Update the `name` and/or metadata labels for an obligation definition. + +If PEPs rely on this obligation name, a name update could break access. + +Make sure you know what you are doing. + +For more information about obligations, see the `obligations` subcommand. + +## Example + +```shell +otdfctl policy obligations update --id 34c62145-5d99-45cb-a732-13cb16270e63 --name new_obligation_name +``` diff --git a/otdfctl/docs/man/policy/obligations/values/_index.md b/otdfctl/docs/man/policy/obligations/values/_index.md new file mode 100644 index 0000000000..c229bb1e9f --- /dev/null +++ b/otdfctl/docs/man/policy/obligations/values/_index.md @@ -0,0 +1,10 @@ +--- +title: Manage obligation values +command: + name: values + aliases: + - val + - value +--- + +Obligation values are the values associated with an obligation. diff --git a/otdfctl/docs/man/policy/obligations/values/create.md b/otdfctl/docs/man/policy/obligations/values/create.md new file mode 100644 index 0000000000..b627f274d2 --- /dev/null +++ b/otdfctl/docs/man/policy/obligations/values/create.md @@ -0,0 +1,59 @@ +--- +title: Create an obligation value +command: + name: create + aliases: + - c + - add + - new + flags: + - name: obligation + shorthand: o + description: Identifier of the associated obligation (ID or FQN) + required: true + - name: value + shorthand: v + description: Value of the obligation (i.e. 'value1', must be unique within the definition) + required: true + - name: triggers + shorthand: t + description: Optional JSON array or file path of obligation trigger(s) to be created and stored on the obligation value. + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' +--- + +Add a value to an obligation in the platform Policy. + +For more information about obligation values, see the `obligations` subcommand. + +## Examples + +Create an obligation value for the obligation with ID '3c51a593-cbf8-419d-b7dc-b656d0bedfbb', and value 'my_value': + +```shell +otdfctl policy obligations values create --obligation 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --value my_value +``` + +### Trigger examples + +You can also create multiple obligation triggers while creating an obligation value. + +Create an obligation value and create a non-scoped trigger that will map to the created value. + +```shell +otdfctl policy obligations values create --obligation 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --value my_value --triggers '[{"action": "read", "attribute_value": "https://test.org/attr/test/value/red"}]' +``` + +Create an obligation value and create a scoped trigger that will map to the created value + +```shell +otdfctl policy obligations values create --obligation 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --value my_value --triggers '[{"action": "read", "attribute_value": "https://test.org/attr/test/value/red", "context": {"pep": {"client_id": "a-pep" }}}]' +``` + +Create an obligation value and triggers, where the triggers come from a json file. + +```shell +otdfctl policy obligations values create --obligation 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --value my_value --triggers "/path/to/file.json" +``` diff --git a/otdfctl/docs/man/policy/obligations/values/delete.md b/otdfctl/docs/man/policy/obligations/values/delete.md new file mode 100644 index 0000000000..75d586f764 --- /dev/null +++ b/otdfctl/docs/man/policy/obligations/values/delete.md @@ -0,0 +1,32 @@ +--- +title: Delete an obligation value +command: + name: delete + flags: + - name: id + shorthand: i + description: ID of the obligation value + - name: fqn + shorthand: f + description: FQN of the obligation value + - name: force + description: Force deletion without interactive confirmation +--- + +Removes an obligation value from platform Policy. + +For more information about obligation values, see the manual for the `values` subcommand. + +## Example + +Delete by ID: + +```shell +otdfctl policy obligations values delete --id 217b300a-47f9-4bee-be8c-d38c880053f7 +``` + +Delete by FQN: + +```shell +otdfctl policy obligations values delete --fqn "https://namespace.com/obl/name/drm/value/expiration" +``` \ No newline at end of file diff --git a/otdfctl/docs/man/policy/obligations/values/get.md b/otdfctl/docs/man/policy/obligations/values/get.md new file mode 100644 index 0000000000..41d994bb54 --- /dev/null +++ b/otdfctl/docs/man/policy/obligations/values/get.md @@ -0,0 +1,34 @@ +--- +title: Get an obligation value +command: + name: get + aliases: + - g + flags: + - name: id + shorthand: i + description: ID of the obligation value + - name: fqn + shorthand: f + description: FQN of the obligation value +--- + +Retrieve an obligation value along with its metadata. + +If both `id` and `fqn` flag values are provided, `id` is preferred. + +For more information about obligation values, see the manual for the `values` subcommand. + +## Example + +Get by ID: + +```shell +otdfctl policy obligations values get --id=3c51a593-cbf8-419d-b7dc-b656d0bedfbb +``` + +Get by FQN: + +```shell +otdfctl policy obligations values get --fqn=https://namespace.com/drm/value/watermark +``` diff --git a/otdfctl/docs/man/policy/obligations/values/update.md b/otdfctl/docs/man/policy/obligations/values/update.md new file mode 100644 index 0000000000..2c7d3711db --- /dev/null +++ b/otdfctl/docs/man/policy/obligations/values/update.md @@ -0,0 +1,58 @@ +--- +title: Update an obligation value +command: + name: update + aliases: + - u + flags: + - name: id + shorthand: i + description: ID of the obligation value to update + required: true + - name: value + shorthand: v + description: Optional updated value of the obligation value (must be unique within the definition) + - name: triggers + shorthand: t + description: Optional JSON array or file path of obligation trigger(s) to be created and stored on the obligation value. + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' +--- + +Update the `value` and/or metadata labels for an obligation value. + +If PEPs rely on this value, a value update could break access. + +Make sure you know what you are doing. + +For more information about obligation values, see the manual for the `values` subcommand. + +## Example + +```shell +otdfctl policy obligations values update --id 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --value new_value --label "hello=world" +``` + +### Trigger Example + +>[!CAUTION] +>Updating a obligation value with triggers will replace all existing +>triggers, on the obligation value being updated, with the new list. + +Update an obligation value and assign one unscoped trigger to the new value. + +>[!NOTE] +>View the `create` command under obligation triggers to read +>more about `scoped` and `unscoped` triggers. + +```shell +otdfctl policy obligations values update --id 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --value new_value --label "hello=world" --triggers '[{"action": "read", "attribute_value": "https://test.org/attr/test/value/red"}]' +``` + +Update triggers on an obligation value via a json file. + +```shell +otdfctl policy obligations values update --id 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --value new_value --label "hello=world" --triggers "/path/to/file.json" +``` diff --git a/otdfctl/docs/man/policy/registered-resources/_index.md b/otdfctl/docs/man/policy/registered-resources/_index.md new file mode 100644 index 0000000000..39523e3c5f --- /dev/null +++ b/otdfctl/docs/man/policy/registered-resources/_index.md @@ -0,0 +1,9 @@ +--- +title: Manage Registered Resources +command: + name: registered-resources + aliases: + - reg-res +--- + +Registered Resources are "non-data" resources (i.e. not a TDF data object) that are registered with the platform policy and may serve as the "Entity" or "Resource" in a decision request. diff --git a/otdfctl/docs/man/policy/registered-resources/create.md b/otdfctl/docs/man/policy/registered-resources/create.md new file mode 100644 index 0000000000..cb9d085c83 --- /dev/null +++ b/otdfctl/docs/man/policy/registered-resources/create.md @@ -0,0 +1,38 @@ +--- +title: Create a Registered Resource +command: + name: create + aliases: + - c + - add + - new + flags: + - name: name + shorthand: n + description: Name of the registered resource (must be unique within a Namespace) + required: true + - name: namespace + shorthand: s + description: Namespace ID or FQN + - name: value + shorthand: v + description: Value of the registered resource (i.e. 'value1', must be unique within the Registered Resource) + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' +--- + +Add a registered resource to the platform Policy. + +A registered resource `name` is normalized to lower case and may contain hyphens or dashes between other alphanumeric characters. + +For more information, see the `registered-resources` subcommand. + +## Examples + +Create a registered resource named 'my_resource' with value 'my_value': + +```shell +otdfctl policy registered-resources create --name my_resource --value my_value +``` diff --git a/otdfctl/docs/man/policy/registered-resources/delete.md b/otdfctl/docs/man/policy/registered-resources/delete.md new file mode 100644 index 0000000000..c6413b7343 --- /dev/null +++ b/otdfctl/docs/man/policy/registered-resources/delete.md @@ -0,0 +1,24 @@ +--- +title: Delete a Registered Resource +command: + name: delete + flags: + - name: id + shorthand: i + description: ID of the registered resource + required: true + - name: force + description: Force deletion without interactive confirmation +--- + +Removes a Registered Resource from platform Policy. + +Registered resource deletion cascades to the associated Registered Resource Values and Action Attribute Values. + +For more information about Registered Resources, see the manual for the `registered-resources` subcommand. + +## Example + +```shell +otdfctl policy registered-resources delete --id 217b300a-47f9-4bee-be8c-d38c880053f7 +``` diff --git a/otdfctl/docs/man/policy/registered-resources/get.md b/otdfctl/docs/man/policy/registered-resources/get.md new file mode 100644 index 0000000000..f2c4f171f3 --- /dev/null +++ b/otdfctl/docs/man/policy/registered-resources/get.md @@ -0,0 +1,37 @@ +--- +title: Get a Registered Resource +command: + name: get + aliases: + - g + flags: + - name: id + shorthand: i + description: ID of the registered resource + - name: name + shorthand: n + description: Name of the registered resource + - name: namespace + shorthand: s + description: Namespace ID or FQN for name-based lookups (optional) +--- + +Retrieve a registered resource along with its metadata and values. + +If both `id` and `name` flag values are provided, `id` is preferred. + +For more information about Registered Resources, see the manual for the `registered-resources` subcommand. + +## Example + +Get by ID: + +```shell +otdfctl policy registered-resources get --id=3c51a593-cbf8-419d-b7dc-b656d0bedfbb +``` + +Get by Name: + +```shell +otdfctl policy registered-resources get --name=my_resource +``` diff --git a/otdfctl/docs/man/policy/registered-resources/list.md b/otdfctl/docs/man/policy/registered-resources/list.md new file mode 100644 index 0000000000..ab7649a16f --- /dev/null +++ b/otdfctl/docs/man/policy/registered-resources/list.md @@ -0,0 +1,25 @@ +--- +title: List Registered Resources +command: + name: list + aliases: + - l + flags: + - name: namespace + shorthand: s + description: Namespace ID or FQN to filter results + - name: limit + shorthand: l + description: Limit retrieved count + - name: offset + shorthand: o + description: Offset (page) quantity from start of the list +--- + +For more information about Registered Resources, see the `registered-resources` subcommand. + +## Example + +```shell +otdfctl policy registered-resources list +``` diff --git a/otdfctl/docs/man/policy/registered-resources/update.md b/otdfctl/docs/man/policy/registered-resources/update.md new file mode 100644 index 0000000000..11b4d43aba --- /dev/null +++ b/otdfctl/docs/man/policy/registered-resources/update.md @@ -0,0 +1,36 @@ +--- +title: Update a Registered Resource +command: + name: update + aliases: + - u + flags: + - name: id + shorthand: i + description: ID of the registered resource to update + required: true + - name: name + shorthand: n + description: Optional updated name of the registered resource (must be unique within Policy) + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' + - name: force-replace-labels + description: Destructively replace entire set of existing metadata 'labels' with any provided to this command + default: false +--- + +Update the `name` and/or metadata labels for a Registered Resource. + +If PEPs rely on this registered resource name, a name update could break access. + +Make sure you know what you are doing. + +For more information about Registered Resources, see the `registered-resources` subcommand. + +## Example + +```shell +otdfctl policy registered-resources update --id 34c62145-5d99-45cb-a732-13cb16270e63 --name new_resource_name +``` diff --git a/otdfctl/docs/man/policy/registered-resources/values/_index.md b/otdfctl/docs/man/policy/registered-resources/values/_index.md new file mode 100644 index 0000000000..552d709af3 --- /dev/null +++ b/otdfctl/docs/man/policy/registered-resources/values/_index.md @@ -0,0 +1,10 @@ +--- +title: Manage Registered Resource Values +command: + name: values + aliases: + - val + - value +--- + +Registered Resource Values are the values associated with a registered resource. diff --git a/otdfctl/docs/man/policy/registered-resources/values/create.md b/otdfctl/docs/man/policy/registered-resources/values/create.md new file mode 100644 index 0000000000..c22bf91aeb --- /dev/null +++ b/otdfctl/docs/man/policy/registered-resources/values/create.md @@ -0,0 +1,43 @@ +--- +title: Create Registered Resource Value +command: + name: create + aliases: + - c + - add + - new + flags: + - name: resource + shorthand: r + description: Identifier of the associated registered resource (ID or name) + required: true + - name: value + shorthand: v + description: Value of the registered resource (i.e. 'value1', must be unique within the Registered Resource) + required: true + - name: namespace + shorthand: s + description: "Namespace ID or FQN (required when --resource is a name)" + - name: action-attribute-value + shorthand: a + description: "Optional action attribute values in the format: \";\"" + default: '' + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' +--- + +Add a value to a registered resource in the platform Policy. + +A registered resource value `value` is normalized to lower case and may contain hyphens or dashes between other alphanumeric characters. + +For more information, see the `registered-resources` subcommand. + +## Examples + +Create a registered resource value for the registered resource with ID '3c51a593-cbf8-419d-b7dc-b656d0bedfbb', value 'my_value', and action attribute values using action/attribute value IDs, action names, and attribute value FQNs: + +```shell +otdfctl policy registered-resources values create --resource 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --value my_value --action-attribute-value "74a3eade-ef6c-4422-b764-fe0471f5c6c1;405a35a7-2051-49a6-9645-3a667b4739f3" --action-attribute-value "create;https://example.com/attr/my_attribute/value/my_value" +``` diff --git a/otdfctl/docs/man/policy/registered-resources/values/delete.md b/otdfctl/docs/man/policy/registered-resources/values/delete.md new file mode 100644 index 0000000000..a7d31ce3bf --- /dev/null +++ b/otdfctl/docs/man/policy/registered-resources/values/delete.md @@ -0,0 +1,24 @@ +--- +title: Delete a Registered Resource Value +command: + name: delete + flags: + - name: id + shorthand: i + description: ID of the registered resource value + required: true + - name: force + description: Force deletion without interactive confirmation +--- + +Removes a Registered Resource Value from platform Policy. + +Registered resource value deletion cascades to the associated Action Attribute Values. + +For more information about Registered Resource Values, see the manual for the `values` subcommand. + +## Example + +```shell +otdfctl policy registered-resources values delete --id 217b300a-47f9-4bee-be8c-d38c880053f7 +``` diff --git a/otdfctl/docs/man/policy/registered-resources/values/get.md b/otdfctl/docs/man/policy/registered-resources/values/get.md new file mode 100644 index 0000000000..31cdac0925 --- /dev/null +++ b/otdfctl/docs/man/policy/registered-resources/values/get.md @@ -0,0 +1,34 @@ +--- +title: Get a Registered Resource Value +command: + name: get + aliases: + - g + flags: + - name: id + shorthand: i + description: ID of the registered resource value + - name: fqn + shorthand: f + description: FQN of the registered resource value +--- + +Retrieve a registered resource value along with its metadata. + +If both `id` and `fqn` flag values are provided, `id` is preferred. + +For more information about Registered Resource Values, see the manual for the `values` subcommand. + +## Example + +Get by ID: + +```shell +otdfctl policy registered-resources values get --id=3c51a593-cbf8-419d-b7dc-b656d0bedfbb +``` + +Get by FQN: + +```shell +otdfctl policy registered-resources values get --fqn=https://reg_res/my_name/value/my_value +``` diff --git a/otdfctl/docs/man/policy/registered-resources/values/list.md b/otdfctl/docs/man/policy/registered-resources/values/list.md new file mode 100644 index 0000000000..f6cac7c22c --- /dev/null +++ b/otdfctl/docs/man/policy/registered-resources/values/list.md @@ -0,0 +1,30 @@ +--- +title: List Registered Resource Values +command: + name: list + aliases: + - l + flags: + - name: resource + shorthand: r + description: Identifier of the associated registered resource (ID or name) + - name: namespace + shorthand: s + description: "Namespace ID or FQN (required when --resource is a name)" + - name: limit + shorthand: l + description: Limit retrieved count + - name: offset + shorthand: o + description: Offset (page) quantity from start of the list +--- + +List registered resource values in the platform Policy. + +For more information about Registered Resource Values, see the manual for the `values` subcommand. + +## Example + +```shell +otdfctl policy registered-resources values list +``` diff --git a/otdfctl/docs/man/policy/registered-resources/values/update.md b/otdfctl/docs/man/policy/registered-resources/values/update.md new file mode 100644 index 0000000000..c578d7aaed --- /dev/null +++ b/otdfctl/docs/man/policy/registered-resources/values/update.md @@ -0,0 +1,40 @@ +--- +title: Update a Registered Resource Value +command: + name: update + aliases: + - u + flags: + - name: id + shorthand: i + description: ID of the registered resource value to update + - name: value + shorthand: v + description: Optional updated value of the registered resource value (must be unique within the Registered Resource) + - name: action-attribute-value + shorthand: a + description: "Optional action attribute values in the format: \";\"" + default: '' + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' + - name: force + description: Force update without interactive confirmation +--- + +Update any or all of the `value`, action attribute values, and metadata labels for a Registered Resource Value. + +If PEPs rely on this value, a value update could break access. + +Updating the action attribute values will remove and replace all existing action attribute values for this registered resource value. + +Make sure you know what you are doing. + +For more information about Registered Resource Values, see the manual for the `values` subcommand. + +## Example + +```shell +otdfctl policy registered-resources values update --id 3c51a593-cbf8-419d-b7dc-b656d0bedfbb --value new_value --action-attribute-value "74a3eade-ef6c-4422-b764-fe0471f5c6c1;405a35a7-2051-49a6-9645-3a667b4739f3" --action-attribute-value "create;https://example.com/attr/my_attribute/value/my_value" +``` diff --git a/otdfctl/docs/man/policy/resource-mapping-groups/_index.md b/otdfctl/docs/man/policy/resource-mapping-groups/_index.md new file mode 100644 index 0000000000..ed0269362b --- /dev/null +++ b/otdfctl/docs/man/policy/resource-mapping-groups/_index.md @@ -0,0 +1,11 @@ +--- +title: Manage resource mapping groups +command: + name: resource-mapping-groups + aliases: + - resmg + - remapgrp + - resource-mapping-group +--- + +Resource mapping groups allow you to organize multiple resource mappings into logical collections. By grouping related resource mappings, you can manage sets of resources more efficiently. This is useful for scenarios where resources share common access controls or need to be managed together as a unit. \ No newline at end of file diff --git a/otdfctl/docs/man/policy/resource-mapping-groups/create.md b/otdfctl/docs/man/policy/resource-mapping-groups/create.md new file mode 100644 index 0000000000..48756b1ee6 --- /dev/null +++ b/otdfctl/docs/man/policy/resource-mapping-groups/create.md @@ -0,0 +1,30 @@ +--- +title: Create a resource mapping group +command: + name: create + aliases: + - add + - new + - c + flags: + - name: namespace-id + description: The ID of the namespace of the group + default: '' + - name: name + description: The name of the group + default: '' + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' +--- + +Create a new group to organize resource mappings. Resource mapping groups belong to a namespace and are identified by a name. + +For more information about resource mapping groups, see the `resource-mapping-groups` subcommand. + +## Examples + +```shell +otdfctl policy resource-mapping-groups create --namespace-id 891cfe85-b381-4f85-9699-5f7dbfe2a9ab --name my-group +``` diff --git a/otdfctl/docs/man/policy/resource-mapping-groups/delete.md b/otdfctl/docs/man/policy/resource-mapping-groups/delete.md new file mode 100644 index 0000000000..1ef4be0875 --- /dev/null +++ b/otdfctl/docs/man/policy/resource-mapping-groups/delete.md @@ -0,0 +1,19 @@ +--- +title: Delete a resource mapping group +command: + name: delete + flags: + - name: id + description: The ID of the resource mapping group to delete + default: '' + - name: force + description: Force deletion without interactive confirmation (dangerous) +--- + +For more information about resource mapping groups, see the `resource-mapping-groups` subcommand. + +## Examples + +```shell +otdfctl policy resource-mapping-groups delete --id=3ff446fb-8fb1-4c04-8023-47592c90370c +``` diff --git a/otdfctl/docs/man/policy/resource-mapping-groups/get.md b/otdfctl/docs/man/policy/resource-mapping-groups/get.md new file mode 100644 index 0000000000..17bc422137 --- /dev/null +++ b/otdfctl/docs/man/policy/resource-mapping-groups/get.md @@ -0,0 +1,19 @@ +--- +title: Get a resource mapping group +command: + name: get + aliases: + - g + flags: + - name: id + description: The ID of the resource mapping group to get. + default: '' +--- + +For more information about resource mapping groups, see the `resource-mapping-groups` subcommand. + +## Examples + +```shell +otdfctl policy resource-mapping-groups get --id=3ff446fb-8fb1-4c04-8023-47592c90370c +``` diff --git a/otdfctl/docs/man/policy/resource-mapping-groups/list.md b/otdfctl/docs/man/policy/resource-mapping-groups/list.md new file mode 100644 index 0000000000..1e4d15833f --- /dev/null +++ b/otdfctl/docs/man/policy/resource-mapping-groups/list.md @@ -0,0 +1,22 @@ +--- +title: List resource mapping groups +command: + name: list + aliases: + - l + flags: + - name: limit + shorthand: l + description: Limit retrieved count + - name: offset + shorthand: o + description: Offset (page) quantity from start of the list +--- + +For more information about resource mapping groups, see the `resource-mapping-groups` subcommand. + +## Examples + +```shell +otdfctl policy resource-mapping-groups list +``` diff --git a/otdfctl/docs/man/policy/resource-mapping-groups/update.md b/otdfctl/docs/man/policy/resource-mapping-groups/update.md new file mode 100644 index 0000000000..96d2e47679 --- /dev/null +++ b/otdfctl/docs/man/policy/resource-mapping-groups/update.md @@ -0,0 +1,34 @@ +--- +title: Update a resource mapping group +command: + name: update + aliases: + - u + flags: + - name: id + description: The ID of the resource mapping group to update. + default: '' + - name: namespace-id + description: The ID of the namespace of the group + default: '' + - name: name + description: The name of the group + default: '' + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' + - name: force-replace-labels + description: Destructively replace entire set of existing metadata 'labels' with any provided to this command + default: false +--- + +Alter the namespace associated with a group, or update the group's name. + +For more information about resource mapping groups, see the `resource-mapping-groups` subcommand. + +## Examples + +```shell +otdfctl policy resource-mapping-groups update --id=3ff446fb-8fb1-4c04-8023-47592c90370c --name new-name +``` diff --git a/otdfctl/docs/man/policy/resource-mappings/_index.md b/otdfctl/docs/man/policy/resource-mappings/_index.md new file mode 100644 index 0000000000..cb7b3836a2 --- /dev/null +++ b/otdfctl/docs/man/policy/resource-mappings/_index.md @@ -0,0 +1,17 @@ +--- +title: Manage resource mappings +command: + name: resource-mappings + aliases: + - resm + - remap + - resource-mapping +--- + +Resource mappings are used to map resources to their respective attribute values based on the terms +that are related to the data. Alone, this service is not very useful, but when combined with a PEP +or PDP that can use the resource mappings it becomes a powerful tool for automating access control. + +As an example, Tagging PDP uses resource mappings to map resources based on the terms found within +the metadata and documents which are sent to it. Combined with the resource mappings it can then +determine which attributes should be applied to the TDF and return those attributes to the PEP. diff --git a/otdfctl/docs/man/policy/resource-mappings/create.md b/otdfctl/docs/man/policy/resource-mappings/create.md new file mode 100644 index 0000000000..f64563446c --- /dev/null +++ b/otdfctl/docs/man/policy/resource-mappings/create.md @@ -0,0 +1,33 @@ +--- +title: Create a resource mapping +command: + name: create + aliases: + - add + - new + - c + flags: + - name: attribute-value-id + description: The ID of the attribute value to map to the resource. + default: '' + - name: terms + description: The synonym terms to match for the resource mapping. + default: '' + - name: group-id + description: The ID of the resource mapping group to assign this mapping to + default: '' + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' +--- + +Associate an attribute value with a set of plaintext string terms. + +For more information about resource mappings, see the `resource-mappings` subcommand. + +## Examples + +```shell +otdfctl policy resource-mappings create --attribute-value-id 891cfe85-b381-4f85-9699-5f7dbfe2a9ab --terms term1,term2 --group-id 3ff446fb-8fb1-4c04-8023-47592c90370c +``` diff --git a/otdfctl/docs/man/policy/resource-mappings/delete.md b/otdfctl/docs/man/policy/resource-mappings/delete.md new file mode 100644 index 0000000000..3640325b14 --- /dev/null +++ b/otdfctl/docs/man/policy/resource-mappings/delete.md @@ -0,0 +1,19 @@ +--- +title: Delete a resource mapping +command: + name: delete + flags: + - name: id + description: The ID of the resource mapping to delete + default: '' + - name: force + description: Force deletion without interactive confirmation (dangerous) +--- + +For more information about resource mappings, see the `resource-mappings` subcommand. + +## Examples + +```shell +otdfctl policy resource-mappings delete --id=3ff446fb-8fb1-4c04-8023-47592c90370c +``` diff --git a/otdfctl/docs/man/policy/resource-mappings/get.md b/otdfctl/docs/man/policy/resource-mappings/get.md new file mode 100644 index 0000000000..abc4a0fbb8 --- /dev/null +++ b/otdfctl/docs/man/policy/resource-mappings/get.md @@ -0,0 +1,19 @@ +--- +title: Get a resource mapping +command: + name: get + aliases: + - g + flags: + - name: id + description: The ID of the resource mapping to get. + default: '' +--- + +For more information about resource mappings, see the `resource-mappings` subcommand. + +## Examples + +```shell +otdfctl policy resource-mappings get --id=3ff446fb-8fb1-4c04-8023-47592c90370c +``` diff --git a/otdfctl/docs/man/policy/resource-mappings/list.md b/otdfctl/docs/man/policy/resource-mappings/list.md new file mode 100644 index 0000000000..402865bad6 --- /dev/null +++ b/otdfctl/docs/man/policy/resource-mappings/list.md @@ -0,0 +1,22 @@ +--- +title: List resource mappings +command: + name: list + aliases: + - l + flags: + - name: limit + shorthand: l + description: Limit retrieved count + - name: offset + shorthand: o + description: Offset (page) quantity from start of the list +--- + +For more information about resource mappings, see the `resource-mappings` subcommand. + +## Examples + +```shell +otdfctl policy resource-mappings get --id=3ff446fb-8fb1-4c04-8023-47592c90370c +``` diff --git a/otdfctl/docs/man/policy/resource-mappings/update.md b/otdfctl/docs/man/policy/resource-mappings/update.md new file mode 100644 index 0000000000..9d38812a10 --- /dev/null +++ b/otdfctl/docs/man/policy/resource-mappings/update.md @@ -0,0 +1,37 @@ +--- +title: Update a resource mapping +command: + name: update + aliases: + - u + flags: + - name: id + description: The ID of the resource mapping to update. + default: '' + - name: attribute-value-id + description: The ID of the attribute value to map to the resource. + default: '' + - name: terms + description: The synonym terms to match for the resource mapping. + default: '' + - name: group-id + description: The ID of the resource mapping group to assign this mapping to + default: '' + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' + - name: force-replace-labels + description: Destructively replace entire set of existing metadata 'labels' with any provided to this command + default: false +--- + +Alter the attribute value associated with a resource mapping's terms, change its group, or fully replace the terms in a given resource mapping. + +For more information about resource mappings, see the `resource-mappings` subcommand. + +## Examples + +```shell +otdfctl policy resource-mappings update --id=3ff446fb-8fb1-4c04-8023-47592c90370c --terms newterm1,newterm2 +``` diff --git a/otdfctl/docs/man/policy/subject-condition-sets/_index.md b/otdfctl/docs/man/policy/subject-condition-sets/_index.md new file mode 100644 index 0000000000..b6f6717886 --- /dev/null +++ b/otdfctl/docs/man/policy/subject-condition-sets/_index.md @@ -0,0 +1,21 @@ +--- +title: Subject condition sets +command: + name: subject-condition-sets + aliases: + - subcs + - scs + - subject-condition-set +--- + +Subject Condition Sets (SCSs) are the logical resolvers of entitlement to attributes. + +An SCS contains AND/OR groups of conditions with IN/NOT_IN/CONTAINS logic to be applied against +a Subject Entity Representation as either their OIDC Access Token claims or the platform's Entity +Resolution Service (ERS). + +They are applied to Attribute Values via Subject Mappings to determine a Subject's entitlement to +any given attribute on TDF'd data. + +For example structure and logical resolution, see `create` subcommand. For information about Subject +Mappings, see the `subject-mappings` command. diff --git a/otdfctl/docs/man/policy/subject-condition-sets/create.md b/otdfctl/docs/man/policy/subject-condition-sets/create.md new file mode 100644 index 0000000000..15df104087 --- /dev/null +++ b/otdfctl/docs/man/policy/subject-condition-sets/create.md @@ -0,0 +1,135 @@ +--- +title: Create a Subject Condition Set + +command: + name: create + aliases: + - c + - add + - new + flags: + - name: subject-sets + description: A JSON array of subject sets, containing a list of condition groups, each with one or more conditions + shorthand: s + required: true + default: '' + - name: subject-sets-file-json + description: A JSON file with path from the current working directory containing an array of subject sets + shorthand: j + default: '' + required: false + - name: namespace + description: Namespace ID or FQN + shorthand: n + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' + - name: force-replace-labels + description: Destructively replace entire set of existing metadata 'labels' with any provided to this command + default: false +--- + +### Example Subject Condition Sets + +`--subject-sets` example input: + +```json +[ + { + "condition_groups": [ + { + "conditions": [ + { + "operator": 1, + "subject_external_values": ["CoolTool", "RadService", "ShinyThing"], + "subject_external_selector_value": ".team.name" + }, + { + "operator": 2, + "subject_external_values": ["marketing"], + "subject_external_selector_value": ".org.name" + } + ], + "boolean_operator": 1 + } + ] + } +] +``` + +ConditionGroup `boolean_operator` is driven through the API `CONDITION_BOOLEAN_TYPE_ENUM` definition: + +| CONDITION_BOOLEAN_TYPE_ENUM | index value | comparison | +| --------------------------- | ----------- | --------------------- | +| AND | 1 | all conditions met | +| OR | 2 | any one condition met | + +Condition `operator` is driven through the API `SUBJECT_MAPPING_OPERATOR_ENUM` definition, +and is evaluated by applying the `subject_external_selector_value` to the Subject entity +representation (token or Entity Resolution Service response) and comparing the logical operator +against the list of `subject_external_values`: + +| SUBJECT_MAPPING_OPERATOR_ENUM | index value | subject value at selector MUST | +| ----------------------------- | ----------- | ------------------------------ | +| IN | 1 | be any of the values | +| NOT_IN | 2 | not be any of the values | +| IN_CONTAINS | 3 | contain one of the values | + +In the example SCS above, the Subject entity MUST BE represented with a token claim or ERS response +containing a field at `.team.name` identifying them as team name "CoolTool", "RadService", or "ShinyThing", AND THEY MUST ALSO have a field `org.name` that is NOT "marketing". + +This structure if their team name was "CoolTool" and they were entitled might look like: + +```json +{ + "team": { + "name": "CoolTool" // could alternatively be RadService or ShinyThing + }, + "org": { + "name": "sales" + } +} +``` + +If any condition in the group is not met (such as if `.org.name` were `marketing` instead), +the condition set would not resolve to true, and the Subject would not be found to be entitled +to the Attribute Value applicable to this Subject Condition Set via Subject Mapping between. + +For more information about subject condition sets, see the `subject-condition-sets` subcommand. + +## Examples + +The following subject condition set would resolve to true if the field at `.example.field.one` is +`myvalue` or `myothervalue1`, or the field at `.example.field.two` is not equal to `notpresentvalue`. +```shell +otdfctl policy subject-condition-set create --subject-sets '[ + { + "condition_groups": [ + { + "conditions": [ + { + "operator": 1, + "subject_external_values": ["myvalue", "myothervalue"], + "subject_external_selector_value": ".example.field.one" + }, + { + "operator": 2, + "subject_external_values": ["notpresentvalue"], + "subject_external_selector_value": ".example.field.two" + } + ], + "boolean_operator": 2 + } + ] + } +]' +``` + +You can perform the same action with the input contained in a file: +```shell +otdfctl policy subject-condition-set create --subject-sets-file-json scs.json + +# Namespaced subject condition set creation +otdfctl policy subject-condition-set create --subject-sets-file-json scs.json --namespace "https://example.com" +``` diff --git a/otdfctl/docs/man/policy/subject-condition-sets/delete.md b/otdfctl/docs/man/policy/subject-condition-sets/delete.md new file mode 100644 index 0000000000..3f269b9321 --- /dev/null +++ b/otdfctl/docs/man/policy/subject-condition-sets/delete.md @@ -0,0 +1,21 @@ +--- +title: Delete a Subject Condition Set + +command: + name: delete + flags: + - name: id + description: The ID of the subject condition set to delete + shorthand: i + required: true + - name: force + description: Force deletion without interactive confirmation (dangerous) +--- + +For more information about subject condition sets, see the `subject-condition-sets` subcommand. + +## Example + +```shell +otdfctl policy subject-condition-sets delete --id=bfade235-509a-4a6f-886a-812005c01db5 +``` diff --git a/otdfctl/docs/man/policy/subject-condition-sets/get.md b/otdfctl/docs/man/policy/subject-condition-sets/get.md new file mode 100644 index 0000000000..fefb284218 --- /dev/null +++ b/otdfctl/docs/man/policy/subject-condition-sets/get.md @@ -0,0 +1,21 @@ +--- +title: Get a Subject Condition Set + +command: + name: get + aliases: + - g + flags: + - name: id + description: The ID of the subject condition set to get + shorthand: i + required: true +--- + +For more information about subject condition sets, see the `subject-condition-sets` subcommand. + +## Example + +```shell +otdfctl policy subject-condition-sets get --id=bfade235-509a-4a6f-886a-812005c01db5 +``` diff --git a/otdfctl/docs/man/policy/subject-condition-sets/list.md b/otdfctl/docs/man/policy/subject-condition-sets/list.md new file mode 100644 index 0000000000..1d489b84f6 --- /dev/null +++ b/otdfctl/docs/man/policy/subject-condition-sets/list.md @@ -0,0 +1,28 @@ +--- +title: List Subject Condition Set + +command: + name: list + aliases: + - l + flags: + - name: namespace + shorthand: n + description: Namespace ID or FQN to filter results + - name: limit + shorthand: l + description: Limit retrieved count + - name: offset + shorthand: o + description: Offset (page) quantity from start of the list +--- + +For more information about subject condition sets, see the `subject-condition-sets` subcommand. + +## Example + +```shell +otdfctl policy subject-condition-set list + +otdfctl policy subject-condition-set list --namespace "https://example.com" +``` diff --git a/otdfctl/docs/man/policy/subject-condition-sets/prune.md b/otdfctl/docs/man/policy/subject-condition-sets/prune.md new file mode 100644 index 0000000000..45806e7b16 --- /dev/null +++ b/otdfctl/docs/man/policy/subject-condition-sets/prune.md @@ -0,0 +1,19 @@ +--- +title: Prune (delete all un-mapped Subject Condition Sets) + +command: + name: prune + flags: + - name: force + description: Force prune without interactive confirmation (dangerous) +--- + +This command will delete all Subject Condition Sets that are not utilized within any Subject Mappings and are therefore 'stranded'. + +For more information about subject condition sets, see the `subject-condition-sets` subcommand. + +## Example + +```shell +otdfctl policy subject-condition-set prune +``` diff --git a/otdfctl/docs/man/policy/subject-condition-sets/update.md b/otdfctl/docs/man/policy/subject-condition-sets/update.md new file mode 100644 index 0000000000..f82783f687 --- /dev/null +++ b/otdfctl/docs/man/policy/subject-condition-sets/update.md @@ -0,0 +1,61 @@ +--- +title: Update a Subject Condition Set + +command: + name: update + aliases: + - u + flags: + - name: id + description: The ID of the subject condition set to update + shorthand: i + required: true + - name: subject-sets + description: A JSON array of subject sets, containing a list of condition groups, each with one or more conditions + shorthand: s + default: '' + - name: subject-sets-file-json + description: A JSON file with path from the current working directory containing an array of subject sets + shorthand: j + default: '' + required: false + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + default: '' + - name: force-replace-labels + description: Destructively replace entire set of existing metadata 'labels' with any provided to this command + default: false +--- + +Replace the existing conditional logic within an SCS with new conditional logic, passing either JSON directly or a JSON file. + +For more information about subject condition sets, see the `subject-condition-sets` subcommand. + +## Example + +This updates the boolean_operator of the subject condition set created in the `create` example. The following subject condition set would resolve to true if the field at `.example.field.one` is +`myvalue` or `myothervalue` AND the field at `.example.field.two` is not equal to `notpresentvalue`. +```shell +otdfctl policy subject-condition-set update --id bfade235-509a-4a6f-886a-812005c01db5 --subject-sets '[ + { + "condition_groups": [ + { + "conditions": [ + { + "operator": 1, + "subject_external_values": ["myvalue", "myothervalue"], + "subject_external_selector_value": ".example.field.one" + }, + { + "operator": 2, + "subject_external_values": ["notpresentvalue"], + "subject_external_selector_value": ".example.field.two" + } + ], + "boolean_operator": 1 + } + ] + } +]' +``` diff --git a/otdfctl/docs/man/policy/subject-mappings/_index.md b/otdfctl/docs/man/policy/subject-mappings/_index.md new file mode 100644 index 0000000000..6ba912a9c3 --- /dev/null +++ b/otdfctl/docs/man/policy/subject-mappings/_index.md @@ -0,0 +1,25 @@ +--- +title: Subject mappings +command: + name: subject-mappings + aliases: + - subm + - sm + - submap + - subject-mapping +--- + +Subject Mappings are the policy mechanism used to entitle Entities to take Actions on Attribute Values. + +In a TDF flow, the resource data is associated to Attribute Values within the TDF manifest policy, +and a Subject Mapping links a given entity (user, principal) to entitled Action(s) on an Attribute Value. + +A Subject Mapping (SM) relates: + 1. one Subject Condition Set (SCS, see `subject-condition-sets` command) + 2. one or more Actions (see `actions` command) + 3. one Attribute Value (see `attributes values` command) + +Within ABAC entitlement decisioning, the principal/agent/user/subject is known via an Entity Representation +provided by the Entity Resolution Service and identity provider, and that Entity Representation is logically +resolved against the Subject Mapping's contained Subject Condition set such that if it is logically true, +the entity is considered entitled to the contained Actions on the contained Attribute Value. diff --git a/otdfctl/docs/man/policy/subject-mappings/create.md b/otdfctl/docs/man/policy/subject-mappings/create.md new file mode 100644 index 0000000000..8349fdca0b --- /dev/null +++ b/otdfctl/docs/man/policy/subject-mappings/create.md @@ -0,0 +1,95 @@ +--- +title: Create a new subject mapping +command: + name: create + aliases: + - new + - add + - c + flags: + - name: attribute-value-id + description: The ID of the attribute value to map to a subject condition set + shorthand: a + required: true + - name: action + description: Each 'id' or 'name' of an Action to be entitled (i.e. 'create', 'read', 'update', 'delete') + - name: subject-condition-set-id + description: Known preexisting Subject Condition Set Id + - name: subject-condition-set-new + description: JSON array of Subject Sets to create a new Subject Condition Set associated with the created Subject Mapping + - name: namespace + description: Namespace ID or FQN + shorthand: n + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + - name: action-standard + description: Deprecated. Migrated to '--action'. + shorthand: s + - name: action-custom + description: Deprecated. Migrated to '--action'. + shorthand: c +--- + +Create a Subject Mapping to entitle an entity (via an existing or new Subject Condition Set) to Action(s) +on an Attribute Value. + +Subject Mappings may entitle Actions with standard names ('create', 'read', 'update', 'delete'), custom names, +or by their stored 'id' within policy. If the referenced Action name does not already exist within policy, +it will be created along with the new Subject Mapping. + +For more information about actions, see the `actions` subcommand. + +For more information about subject mappings, see the `subject-mappings` subcommand. + +For more information about subject condition sets, see the `subject-condition-sets` subcommand. + +## Namespacing subject mappings + +The following rules must be applied when attempting to namespace a subject mapping: + +- Either all policy constructs (action, subject mappings, subject condition set, attribute value) are within the same + namespace +- Subject mapping, subject condition set, action are all not within a namespace. + +You cannot, for example: + +- Create a subject mapping that is not within the same namespace as an action that is passed in + +## Examples + +Create a subject mapping for a 'read' action linking to an existing subject condition set: +```shell +otdfctl policy subject-mapping create --attribute-value-id 891cfe85-b381-4f85-9699-5f7dbfe2a9ab --action read --subject-condition-set-id 8dc98f65-5f0a-4444-bfd1-6a818dc7b447 +``` + +Or you can create a mapping for 'read' or 'create' linking to a new subject condition set: +```shell +otdfctl policy subject-mapping create --attribute-value-id 891cfe85-b381-4f85-9699-5f7dbfe2a9ab --action create --action update --subject-condition-set-new '[ + { + "condition_groups": [ + { + "conditions": [ + { + "operator": 1, + "subject_external_values": ["myvalue", "myothervalue"], + "subject_external_selector_value": ".example.field.one" + }, + { + "operator": 2, + "subject_external_values": ["notpresentvalue"], + "subject_external_selector_value": ".example.field.two" + } + ], + "boolean_operator": 2 + } + ] + } +]' +``` + +Create a subject mapping under a namespace + +```shell +otdfctl policy subject-mapping create --attribute-value-id 891cfe85-b381-4f85-9699-5f7dbfe2a9ab --action read --subject-condition-set-id 8dc98f65-5f0a-4444-bfd1-6a818dc7b447 --namespace "https://example.com" +``` diff --git a/otdfctl/docs/man/policy/subject-mappings/delete.md b/otdfctl/docs/man/policy/subject-mappings/delete.md new file mode 100644 index 0000000000..1a00549d88 --- /dev/null +++ b/otdfctl/docs/man/policy/subject-mappings/delete.md @@ -0,0 +1,25 @@ +--- +title: Delete a subject mapping by id +command: + name: delete + flags: + - name: id + description: The ID of the subject mapping to delete + shorthand: i + required: true + default: '' + - name: force + description: Force deletion without interactive confirmation (dangerous) +--- + +Delete a Subject Mapping to remove entitlement of an entity (via Subject Condition Set) to an Attribute Value. + +For more information about subject mappings, see the `subject-mappings` subcommand. + +For more information about subject condition sets, see the `subject-condition-sets` subcommand. + +## Example + +```shell +otdfctl policy subject-mappings delete --id d71c4028-ce64-453b-8aa7-6edb45fbb848 +``` diff --git a/otdfctl/docs/man/policy/subject-mappings/get.md b/otdfctl/docs/man/policy/subject-mappings/get.md new file mode 100644 index 0000000000..bc52c6f2ba --- /dev/null +++ b/otdfctl/docs/man/policy/subject-mappings/get.md @@ -0,0 +1,21 @@ +--- +title: Get a subject mapping +command: + name: get + aliases: + - g + flags: + - name: id + description: The ID of the subject mapping to get + shorthand: i + required: true + default: '' +--- + +Retrieve the specifics of a Subject Mapping. + +For more information about subject mappings, see the `subject-mappings` subcommand. + +```shell +otdfctl policy subject-mappings get --id 39866dd2-368b-41f6-b292-b4b68c01888b +``` diff --git a/otdfctl/docs/man/policy/subject-mappings/list.md b/otdfctl/docs/man/policy/subject-mappings/list.md new file mode 100644 index 0000000000..2a65e61c89 --- /dev/null +++ b/otdfctl/docs/man/policy/subject-mappings/list.md @@ -0,0 +1,27 @@ +--- +title: List subject mappings +command: + name: list + aliases: + - l + flags: + - name: namespace + shorthand: n + description: Namespace ID or FQN to filter results + - name: limit + shorthand: l + description: Limit retrieved count + - name: offset + shorthand: o + description: Offset (page) quantity from start of the list +--- + +For more information about subject mappings, see the `subject-mappings` subcommand. + +## Example + +```shell +otdfctl policy subject-mappings list + +otdfctl policy subject-mappings list --namespace "https://example.com" +``` diff --git a/otdfctl/docs/man/policy/subject-mappings/match.md b/otdfctl/docs/man/policy/subject-mappings/match.md new file mode 100644 index 0000000000..4791b19a57 --- /dev/null +++ b/otdfctl/docs/man/policy/subject-mappings/match.md @@ -0,0 +1,41 @@ +--- +title: Match a subject or set of selectors to relevant subject mappings +command: + name: match + flags: + - name: subject + shorthand: s + description: A Subject Entity Representation string (JSON or JWT, auto-detected) + default: '' + - name: selector + shorthand: x + description: "Individual selectors (i.e. '.department' or '.realm_access.roles[]') that may be found in SubjectConditionSets" +--- + +This tool queries platform policies for relevant Subject Mappings using either an Entity Representation or specific selectors. + +If an Entity Representation is provided via `--subject` (such as an OIDC JWT or JSON response from an Entity Resolution Service), the tool +parses all valid selectors and checks for matching Subject Condition Sets in Subject Mappings to Attribute Values. + +If selectors are provided directly with `--selector`, the tool searches for Subject Mappings with Subject Condition Sets that contain those selectors. + +## Examples + +Various ways to invoke the `match` command to query Subject Mappings to Attribute Values with relevant Subject Condition Sets. + +```shell +# matches either org name or department selectors +otdfctl policy subject-mappings match --selector '.org.name' --selector '.department' + +# parses subject entity representation as JSON and matches any selector (with this subject only '.emailAddress') +otdfctl policy subject-mappings match --subject '{"emailAddress":"user@email.com"}' + +# parses entity representation as JWT into all possicle claim selectors and matches any of them +otdfctl policy subject-mappings match --subject 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' +``` + +> [!NOTE] +> The values of the selectors and any `IN`/`NOT_IN`/`IN_CONTAINS` logic of Subject Condition Sets is irrelevant to this command. +> Evaluation of any matched conditions is handled by the Authorization Service to determine entitlements. This command +> is specifically for management of policy - to facilitate lookup of current conditions driven by known selectors as a +> precondition for administration of entitlement given the logical _operators_ of the matched conditions and their relations. diff --git a/otdfctl/docs/man/policy/subject-mappings/update.md b/otdfctl/docs/man/policy/subject-mappings/update.md new file mode 100644 index 0000000000..602544b659 --- /dev/null +++ b/otdfctl/docs/man/policy/subject-mappings/update.md @@ -0,0 +1,45 @@ +--- +title: Update a subject mapping +command: + name: update + aliases: + - u + flags: + - name: id + description: The ID of the subject mapping to update + shorthand: i + required: true + - name: action + description: Each 'id' or 'name' of an Action to be entitled (i.e. 'create', 'read', 'update', 'delete') + - name: action-standard + description: Deprecated. Migrated to '--action'. + shorthand: s + - name: action-custom + description: Deprecated. Migrated to '--action'. + shorthand: c + - name: subject-condition-set-id + description: Known preexisting Subject Condition Set Id + required: false + - name: label + description: "Optional metadata 'labels' in the format: key=value" + shorthand: l + - name: force-replace-labels + description: Destructively replace entire set of existing metadata 'labels' with any provided to this command + default: false +--- + +Update a Subject Mapping to alter entitlement of an entity to an Attribute Value. + +`Actions` are updated in place, destructively replacing the current set. If you want to add or remove actions, you must provide the full set of actions on update. + +At this time, creation of a new SCS during update of a subject mapping is not supported. + +For more information about subject mappings, see the `subject-mappings` subcommand. + +For more information about subject condition sets, see the `subject-condition-sets` subcommand. + +## Example + +```shell +otdfctl policy subject-mappings update --id 39866dd2-368b-41f6-b292-b4b68c01888b --action read +``` diff --git a/otdfctl/e2e/action.yaml b/otdfctl/e2e/action.yaml new file mode 100644 index 0000000000..c66f9031fa --- /dev/null +++ b/otdfctl/e2e/action.yaml @@ -0,0 +1,92 @@ +name: 'end-to-end' +description: 'Run end-to-end tests for the otdfctl CLI' +inputs: + testrail-run-name-for-cli-test: + required: false + description: 'The name to use for the TestRail test run created for the CLI tests' + default: '' + +runs: + using: 'composite' + steps: + # Build the CLI and run tests + - name: Set up Go + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 + with: + go-version-file: otdfctl/go.mod + - name: Build the CLI + shell: bash + run: go build . + working-directory: otdfctl + - name: Build the CLI in test mode + shell: bash + run: make build-test + working-directory: otdfctl + - name: Build v0.26.2 CLI for keyring profiles + shell: bash + working-directory: otdfctl + run: | + git worktree add ../otdfctl_v0.26.2 otdfctl/v0.26.2 + cd ../otdfctl_v0.26.2 + GOWORK=off go build -o ../otdfctl/otdfctl_v0.26.2 . + echo "LEGACY_OTDFCTL_BIN=./otdfctl_v0.26.2" >> $GITHUB_ENV + - name: Install keyring dependencies + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y gnome-keyring + working-directory: otdfctl + - name: Setup Bats and bats libs + uses: bats-core/bats-action@2.0.0 + - name: Unlock Gnome keyring + shell: bash + run: | + # This is a fake dummy password, that is not used + # anywhere else in the CI, but for bats testing + echo "somecredstorepass" | gnome-keyring-daemon --unlock + - name: Run Bats tests + shell: bash + working-directory: otdfctl + run: | + # Run the namespaced-policy migration suite separately before the rest + # of the e2e directory. Those tests discover legacy/global objects by + # scope and are not safe to overlap with the parallel remainder of the + # suite while other files still create unnamespaced policy fixtures. + bats --tap e2e --filter-tags namespaced_policy_migration | tee e2e/bats-results.tap + + if command -v parallel >/dev/null 2>&1; then + echo "GNU parallel found, running remaining tests in parallel" + bats --tap e2e --filter-tags '!namespaced_policy_migration' --jobs 4 --no-parallelize-within-files --no-tempdir-cleanup | tee -a e2e/bats-results.tap + else + echo "GNU parallel not found, running remaining tests sequentially" + bats --tap e2e --filter-tags '!namespaced_policy_migration' | tee -a e2e/bats-results.tap + fi + env: + # Define 'bats' install location in ubuntu + BATS_LIB_PATH: /usr/lib + # Terminal width for testing printed output + TEST_TERMINAL_WIDTH: 200 + - name: Upload Bats TAP results + if: always() # ensures test report is uploaded even if tests fail + uses: actions/upload-artifact@v4 + with: + name: bats-test-results + path: otdfctl/e2e/bats-results.tap + - name: Integrate Bats test results into TestRail + shell: bash + working-directory: otdfctl + run: | + cd e2e + cp ./testrail-integration/samples-for-virtru-instance/testrail-virtru.config.json ./testrail-integration/testrail.config.json + cp ./testrail-integration/samples-for-virtru-instance/testname-to-testrail-id.virtru.json ./testrail-integration/testname-to-testrail-id.json + ./testrail-integration/upload-bats-test-results-to-testrail.sh + continue-on-error: true + env: + TESTRAIL_USER: ${{ env.TESTRAIL_USER }} + TESTRAIL_PASS: ${{ env.TESTRAIL_PASS }} + TESTRAIL_CLI_RUN_NAME: ${{ inputs.testrail-run-name-for-cli-test }} + - name: Upload TestRail mapping report + uses: actions/upload-artifact@v4 + with: + name: test-cases-mapping-report + path: otdfctl/e2e/mapping-report.txt diff --git a/otdfctl/e2e/actions.bats b/otdfctl/e2e/actions.bats new file mode 100644 index 0000000000..95411cc298 --- /dev/null +++ b/otdfctl/e2e/actions.bats @@ -0,0 +1,239 @@ +#!/usr/bin/env bats + +# Tests for actions + +setup_file() { + export WITH_CREDS='--with-client-creds-file ./creds.json' + export HOST='--host http://localhost:8080' + export ACTION_NAMESPACE_NAME='test-act.org' + export ACTION_NAMESPACE="https://$ACTION_NAMESPACE_NAME" + # create namespace first (needed for action creation) + export NS_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes namespaces create --name "$ACTION_NAMESPACE_NAME" --json | jq -r '.id') +} + +setup() { + load "${BATS_LIB_PATH}/bats-support/load.bash" + load "${BATS_LIB_PATH}/bats-assert/load.bash" + + # invoke binary with credentials + run_otdfctl_action () { + run sh -c "./otdfctl $HOST $WITH_CREDS policy actions $*" + } +} + +teardown_file() { + # clear out all test env vars + # remove the namespace and cascade delete attributes and values used in registered resource values tests + ./otdfctl $HOST $WITH_CREDS policy attributes namespaces unsafe delete --id "$NS_ID" --force + unset HOST WITH_CREDS ACTION_NAMESPACE ACTION_NAMESPACE_NAME NS_ID +} + + +@test "Create a new custom action - Good" { + # with a namespace + run_otdfctl_action create --name test_action_create_namespaced --namespace "$ACTION_NAMESPACE" + assert_output --partial "SUCCESS" + assert_line --regexp "Name.*test_action_create_namespaced" + assert_line --regexp "Namespace.*$ACTION_NAMESPACE" + assert_output --partial "Id" + assert_output --partial "Created At" + assert_line --partial "Updated At" + + # cleanup + created_id=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + run_otdfctl_action delete --id $created_id --force + + # without a namespace (should default to un-namespaced) + run_otdfctl_action create --name test_action_create + assert_output --partial "SUCCESS" + assert_line --regexp "Name.*test_action_create" + assert_output --partial "Id" + assert_output --partial "Created At" + assert_line --partial "Updated At" + # ensure namespace is empty for un-namespaced actions + refute_line --regexp "Namespace.*$ACTION_NAMESPACE" + + created_id=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + run_otdfctl_action delete --id $created_id --force +} + +@test "Create a new action - Bad" { + # bad action names + run_otdfctl_action create --name ends_underscored_ --namespace "$ACTION_NAMESPACE" + assert_failure + run_otdfctl_action create --name -first-char-hyphen --namespace "$ACTION_NAMESPACE" + assert_failure + run_otdfctl_action create --name inval!d.chars --namespace "$ACTION_NAMESPACE" + assert_failure + + # missing flag + run_otdfctl_action create --namespace "$ACTION_NAMESPACE" + assert_failure + assert_output --partial "Flag '--name' is required" + + # TODO: re-enable when namespace is required + # run_otdfctl_action create --name no_namespace + # assert_failure + # assert_output --partial "Flag '--namespace' is required" + + # conflict + run_otdfctl_action create -n "read" --namespace "$ACTION_NAMESPACE" + assert_failure + assert_output --partial "intended action would violate a restriction" + + run_otdfctl_action create -n "read" + assert_failure + assert_output --partial "intended action would violate a restriction" + + # duplicate custom action + run_otdfctl_action create --name test_action_conflict --namespace "$ACTION_NAMESPACE" --json + assert_success + conflict_action_id=$(echo "$output" | jq -er '.id') + assert_success + [ -n "$conflict_action_id" ] + + run_otdfctl_action create --name test_action_conflict --namespace "$ACTION_NAMESPACE" + assert_failure + assert_output --partial "already_exists" + + # cleanup + run_otdfctl_action delete --id "$conflict_action_id" --force +} + +@test "Get an action - Good" { + run_otdfctl_action get --name "read" --namespace "$ACTION_NAMESPACE" + assert_success + assert_line --partial "Id" + assert_line --regexp "Name.*read" + assert_line --regexp "Namespace.*$ACTION_NAMESPACE" + + # get by name to retrieve the ID + UPDATE_ACTION_ID=$(./otdfctl policy actions get --name update --namespace "$ACTION_NAMESPACE" --json $HOST $WITH_CREDS | jq -r '.id') + + # ensure getting by id does not require namespace + run_otdfctl_action get --id "$UPDATE_ACTION_ID" --json + assert_success + [ "$(echo "$output" | jq -r '.id')" = "$UPDATE_ACTION_ID" ] + [ "$(echo "$output" | jq -r '.name')" = "update" ] + + # ensure you can use the namespace id instead of the fqn + run_otdfctl_action get --name "read" --namespace "$NS_ID" + assert_success + assert_line --partial "Id" + assert_line --regexp "Name.*read" + assert_line --regexp "Namespace.*$ACTION_NAMESPACE" + + # ensure get without namespace still works for un-namespaced actions + run_otdfctl_action get --name "read" + assert_success + assert_line --partial "Id" + assert_line --regexp "Name.*read" + refute_line --regexp "Namespace.*$ACTION_NAMESPACE" +} + +@test "Get an action - Bad" { + run_otdfctl_action get + assert_failure + assert_output --partial "Either 'id' or 'name' must be provided" + + run_otdfctl_action get --id 'testing_get' + assert_failure + assert_output --partial "must be a valid UUID" + + # TODO: re-enable when namespace is required + # run_otdfctl_action get --name 'testing_get' + # assert_failure + # assert_output --partial "namespace' must be provided when using 'name'" +} + +@test "List actions" { + run_otdfctl_action create --name test_action_list_namespaced --namespace "$ACTION_NAMESPACE" --json + assert_success + created_id=$(echo "$output" | jq -r '.id') + run_otdfctl_action create --name test_action_list_unnamespaced --json + assert_success + created_id_2=$(echo "$output" | jq -r '.id') + + run_otdfctl_action list --namespace "$ACTION_NAMESPACE" + assert_output --partial "Namespace" + assert_output --partial "$ACTION_NAMESPACE" + assert_output --partial "create" + assert_output --partial "read" + assert_output --partial "update" + assert_output --partial "delete" + assert_output --partial "Total" + assert_line --regexp "Current Offset.*0" + assert_output --partial "test_action_list_namespaced" + refute_output --partial "test_action_list_unnamespaced" + + run_otdfctl_action list --namespace "$ACTION_NAMESPACE" --json + assert_success + assert_not_equal $(echo "$output" | jq -r 'pagination') "null" + assert_output --partial "create" + assert_output --partial "read" + assert_output --partial "update" + assert_output --partial "delete" + total=$(echo "$output" | jq -r '.pagination.total') + [[ "$total" -ge 1 ]] + + # listing without namespace should succeed and should include both namespaced and un-namespaced actions (namespace field should be empty for un-namespaced actions) + run_otdfctl_action list + assert_output --partial "create" + assert_output --partial "read" + assert_output --partial "update" + assert_output --partial "delete" + assert_output --partial "Total" + assert_line --regexp "Current Offset.*0" + assert_output --partial "test_action_list_namespaced" + assert_output --partial "test_action_list_unnamespaced" + + run_otdfctl_action delete --id $created_id --force + run_otdfctl_action delete --id $created_id_2 --force +} + +@test "Update action" { + ACTION_TO_UPDATE=$(./otdfctl policy actions create --name testing_updation --namespace "$ACTION_NAMESPACE" $HOST $WITH_CREDS --json | jq -r '.id') + # extend labels + run_otdfctl_action update --id "$ACTION_TO_UPDATE" -l key=value --label test=true + assert_success + assert_line --regexp "Id.*$ACTION_TO_UPDATE" + assert_line --regexp "Name.*testing_updation" + assert_line --regexp "Namespace.*$ACTION_NAMESPACE" + assert_line --regexp "Labels.*key: value" + assert_line --regexp "Labels.*test: true" + + # force replace labels + run_otdfctl_action update --id "$ACTION_TO_UPDATE" -l key=other --force-replace-labels + assert_success + assert_line --regexp "Id.*$ACTION_TO_UPDATE" + assert_line --regexp "Name.*testing_updation" + assert_line --regexp "Namespace.*$ACTION_NAMESPACE" + assert_line --regexp "Labels.*key: other" + refute_output --regexp "Labels.*key: value" + refute_output --regexp "Labels.*test: true" + refute_output --regexp "Labels.*test: true" + + # renamed + run_otdfctl_action update --id "$ACTION_TO_UPDATE" --name updated_action_in_test + assert_success + assert_line --regexp "Id.*$ACTION_TO_UPDATE" + assert_line --regexp "Name.*updated_action_in_test" + assert_line --regexp "Namespace.*$ACTION_NAMESPACE" + refute_output --regexp "Name.*testing_updation" + + # clean up + run_otdfctl_action delete --id "$ACTION_TO_UPDATE" --force +} + +@test "Delete action - bad" { + STANDARD_ACTION=$(./otdfctl policy actions get --name update --namespace "$ACTION_NAMESPACE" $HOST $WITH_CREDS --json | jq -r '.id') + run_otdfctl_action delete --id "$STANDARD_ACTION" --force + assert_failure +} + +@test "Delete action - good" { + DELETABLE_ACTION=$(./otdfctl policy actions create --name testing-delete --namespace "$ACTION_NAMESPACE" $HOST $WITH_CREDS --json | jq -r '.id') + run_otdfctl_action delete --id "$DELETABLE_ACTION" --force + assert_success + assert_line --regexp "Namespace.*$ACTION_NAMESPACE" +} diff --git a/otdfctl/e2e/attributes.bats b/otdfctl/e2e/attributes.bats new file mode 100755 index 0000000000..20ca399106 --- /dev/null +++ b/otdfctl/e2e/attributes.bats @@ -0,0 +1,507 @@ +#!/usr/bin/env bats + +load "${BATS_LIB_PATH}/bats-support/load.bash" +load "${BATS_LIB_PATH}/bats-assert/load.bash" +load "otdfctl-utils.sh" + +# Tests for attributes + +setup_file() { + export WITH_CREDS='--with-client-creds-file ./creds.json' + export HOST='--host http://localhost:8080' + + # Create the namespace to be used by other tests + + export NS_NAME="testing-attr.co" + export NS_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes namespaces create -n "$NS_NAME" --json | jq -r '.id') + + export KAS_URI="https://test-kas-for-attributes.com" + export KAS_REG_ID=$(./otdfctl $HOST $WITH_CREDS policy kas-registry create --uri "$KAS_URI" --json | jq -r '.id') + # Generate a valid RSA public key and base64 encode (single-line) + export PEM_B64=$(openssl genrsa 2048 2>/dev/null | openssl rsa -pubout 2>/dev/null | base64 | tr -d '\n') + export KAS_KEY_ID="test-key-for-attr" + export KAS_KEY_SYSTEM_ID=$(./otdfctl $HOST $WITH_CREDS policy kas-registry key create --kas "$KAS_REG_ID" --key-id "$KAS_KEY_ID" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json | jq -r '.key.id') + export PEM=$(echo "$PEM_B64" | base64 -d) +} + +# always create a randomly named attribute +setup() { + # invoke binary with credentials + run_otdfctl_attr() { + run sh -c "./otdfctl $HOST $WITH_CREDS policy attributes $*" + } + + export ATTR_NAME_RANDOM=$(LC_ALL=C tr -dc 'a-zA-Z' = 10 attribute definitions + for i in {1..10}; do + random_name=$(LC_ALL=C tr -dc 'A-Za-z0-9' bad_creds.json + BAD_CREDS="--with-client-creds-file ./bad_creds.json" + run_otdfctl $HOST $BAD_CREDS policy attributes list + assert_failure + assert_output --partial "Failed to authenticate with flag-provided client credentials" + + # malformed JSON + BAD_CREDS="--with-client-creds '{clientId:"badClient",clientSecret:"badSecret"}'" + run_otdfctl $HOST $BAD_CREDS policy attributes list + assert_failure + assert_output --partial "Failed to get client credentials" +} + +@test "helpful error if missing client credentials" { + run_otdfctl $HOST policy attributes list + assert_failure + assert_output --partial "One of" + assert_output --partial "must be set: when using global flags" +} + +@test "helpful error if missing host" { + run_otdfctl $WITH_CREDS policy attributes list + assert_failure + assert_output --partial "Host must be set: when using global flags" +} \ No newline at end of file diff --git a/otdfctl/e2e/encrypt-decrypt.bats b/otdfctl/e2e/encrypt-decrypt.bats new file mode 100755 index 0000000000..cc9555ac5f --- /dev/null +++ b/otdfctl/e2e/encrypt-decrypt.bats @@ -0,0 +1,326 @@ +#!/usr/bin/env bats + +# Tests for encrypt decrypt + +setup_file() { + + # TODO: Remove this file-level skip once otdfctl passes namespace flags for the namespaced action and subject mapping APIs used by encrypt/decrypt entitlement setup. + skip "Temporarily disabled [namespaced-subject-mappings]: encrypt/decrypt BATS setup still depends on pre-namespace subject mapping APIs" + + export CREDSFILE=creds.json + echo -n '{"clientId":"opentdf","clientSecret":"secret"}' > $CREDSFILE + export WITH_CREDS="--with-client-creds-file $CREDSFILE" + export DEBUG_LEVEL="--log-level debug" + export HOST=http://localhost:8080 + + export INFILE_GO_MOD=go.mod + export OUTFILE_GO_MOD=go.mod.tdf + export RESULTFILE_GO_MOD=result.mod + export SESSION_KEY_ALGORITHM=ec:secp256r1 + export WRAPPING_KEY_ALGORITHM=ec:secp256r1 + + export SECRET_TEXT="my special secret" + export OUT_TXT=secret.txt + export OUTFILE_TXT=secret.txt.tdf + + NS_ID=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy attributes namespaces create -n "testing-enc-dec.io" --json | jq -r '.id') + ATTR_ID=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy attributes create --namespace "$NS_ID" -n attr1 -r ALL_OF --json | jq -r '.id') + VAL_ID=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy attributes values create --attribute-id "$ATTR_ID" -v value1 --json | jq -r '.id') + ATTR_OBL_VAL_OUTPUT=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy attributes values create --attribute-id "$ATTR_ID" -v test_attr_obligation_value --json) + export ATTR_OBL_VAL_ID=$(echo $ATTR_OBL_VAL_OUTPUT | jq -r '.id') + export ATTR_OBL_VAL_FQN=$(echo $ATTR_OBL_VAL_OUTPUT | jq -r '.fqn') + + # Create obligations + OBL_ID=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy obligations create -n test_obligation -s "$NS_ID" --json | jq -r '.id') + OBL_VAL_OUTPUT=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy obligations values create -o "$OBL_ID" -v test_obligation_value --json) + OBL_VAL_ID=$(echo $OBL_VAL_OUTPUT | jq -r '.id') + export OBL_VAL_FQN=$(echo $OBL_VAL_OUTPUT | jq -r '.fqn') + OBL_TRIG_ID=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy obligations triggers create --obligation-value "$OBL_VAL_ID" --attribute-value "$ATTR_OBL_VAL_FQN" --action "read" --json | jq -r '.id') + + # entitles opentdf client id for client credentials CLI user + SCS='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["opentdf"],"subject_external_selector_value":".clientId"}],"boolean_operator":2}]}]' + + # assertions setup + HS256_KEY=$(openssl rand -base64 32) + RS_PRIVATE_KEY=rs_private_key.pem + RS_PUBLIC_KEY=rs_public_key.pem + openssl genpkey -algorithm RSA -out $RS_PRIVATE_KEY -pkeyopt rsa_keygen_bits:2048 + openssl rsa -pubout -in $RS_PRIVATE_KEY -out $RS_PUBLIC_KEY + + export ASSERTIONS='[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"}}]' + + export SIGNED_ASSERTIONS_HS256=signed_assertions_hs256.json + export SIGNED_ASSERTION_VERIFICATON_HS256=assertion_verification_hs256.json + export SIGNED_ASSERTIONS_RS256=signed_assertion_rs256.json + export SIGNED_ASSERTION_VERIFICATON_RS256=assertion_verification_rs256.json + echo '[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"},"signingKey":{"alg":"HS256","key":"replace"}}]' > $SIGNED_ASSERTIONS_HS256 + jq --arg pem "$(echo $HS256_KEY)" '.[0].signingKey.key = $pem' $SIGNED_ASSERTIONS_HS256 > tmp.json && mv tmp.json $SIGNED_ASSERTIONS_HS256 + echo '{"keys":{"assertion1":{"alg":"HS256","key":"replace"}}}' > $SIGNED_ASSERTION_VERIFICATON_HS256 + jq --arg pem "$(echo $HS256_KEY)" '.keys.assertion1.key = $pem' $SIGNED_ASSERTION_VERIFICATON_HS256 > tmp.json && mv tmp.json $SIGNED_ASSERTION_VERIFICATON_HS256 + echo '[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"},"signingKey":{"alg":"RS256","key":"replace"}}]' > $SIGNED_ASSERTIONS_RS256 + jq --arg pem "$(<$RS_PRIVATE_KEY)" '.[0].signingKey.key = $pem' $SIGNED_ASSERTIONS_RS256 > tmp.json && mv tmp.json $SIGNED_ASSERTIONS_RS256 + echo '{"keys":{"assertion1":{"alg":"RS256","key":"replace"}}}' > $SIGNED_ASSERTION_VERIFICATON_RS256 + jq --arg pem "$(<$RS_PUBLIC_KEY)" '.keys.assertion1.key = $pem' $SIGNED_ASSERTION_VERIFICATON_RS256 > tmp.json && mv tmp.json $SIGNED_ASSERTION_VERIFICATON_RS256 + + + SM=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy subject-mappings create --action 'read' -a "$VAL_ID" --subject-condition-set-new "$SCS") + export FQN="https://testing-enc-dec.io/attr/attr1/value/value1" + export MIXED_CASE_FQN="https://Testing-Enc-Dec.io/attr/Attr1/value/VALUE1" +} + +setup() { + load "${BATS_LIB_PATH}/bats-support/load.bash" + load "${BATS_LIB_PATH}/bats-assert/load.bash" +} + +teardown() { + rm -f $OUTFILE_GO_MOD $RESULTFILE_GO_MOD $OUTFILE_TXT +} + +teardown_file(){ + rm -f $SIGNED_ASSERTIONS_HS256 $SIGNED_ASSERTION_VERIFICATON_HS256 $SIGNED_ASSERTIONS_RS256 $SIGNED_ASSERTION_VERIFICATON_RS256 +} + +@test "roundtrip TDF3, no attributes, file" { + ./otdfctl encrypt -o $OUTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --tdf-type tdf3 $INFILE_GO_MOD + ./otdfctl decrypt -o $RESULTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --tdf-type tdf3 $OUTFILE_GO_MOD + diff $INFILE_GO_MOD $RESULTFILE_GO_MOD +} + +@test "roundtrip TDF3, no attributes, ec-wrapping, file" { + ./otdfctl encrypt -o $OUTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --tdf-type tdf3 --wrapping-key-algorithm $WRAPPING_KEY_ALGORITHM $INFILE_GO_MOD + ./otdfctl decrypt -o $RESULTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --tdf-type tdf3 --session-key-algorithm $SESSION_KEY_ALGORITHM $OUTFILE_GO_MOD + diff $INFILE_GO_MOD $RESULTFILE_GO_MOD +} + +@test "roundtrip TDF3, one attribute, stdin" { + echo $SECRET_TEXT | ./otdfctl encrypt -o $OUT_TXT --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS -a $FQN + ./otdfctl decrypt --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS $OUTFILE_TXT | grep "$SECRET_TEXT" +} + +@test "roundtrip TDF3, one attribute, mixed case FQN, stdin" { + echo $SECRET_TEXT | ./otdfctl encrypt -o $OUT_TXT --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS -a $MIXED_CASE_FQN + ./otdfctl decrypt --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS $OUTFILE_TXT | grep "$SECRET_TEXT" +} + +@test "allow traversal with mapped key uses definition when value missing" { + local attr_name="attr-allow-traversal-${RANDOM}" + local kas_name="kas-allow-traversal-${RANDOM}" + local kas_uri="https://kas-allow-traversal-${RANDOM}.example.com" + local key_id="allow-traversal-key-${RANDOM}" + local ns_id="$NS_ID" + + if [[ -z "$ns_id" ]]; then + ns_id=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy attributes namespaces list --json | jq -r '.namespaces[] | select(.name=="testing-enc-dec.io") | .id' | head -n 1) + fi + if [[ -z "$ns_id" ]]; then + echo "Failed to resolve namespace id for testing-enc-dec.io" + return 1 + fi + + attr_output=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy attributes create --namespace "$ns_id" -n "$attr_name" -r HIERARCHY --allow-traversal --json) + attr_id=$(echo "$attr_output" | jq -r '.id') + attr_fqn=$(echo "$attr_output" | jq -r '.fqn') + missing_value_fqn="${attr_fqn}/value/missing" + + kas_output=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy kas-registry create --uri "$kas_uri" -n "$kas_name" --json) + kas_id=$(echo "$kas_output" | jq -r '.id') + + pem_b64=$(openssl genrsa 2048 2>/dev/null | openssl rsa -pubout 2>/dev/null | base64 | tr -d '\n') + key_output=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy kas-registry key create --kas "$kas_id" --key-id "$key_id" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "$pem_b64" --json) + key_system_id=$(echo "$key_output" | jq -r '.key.id') + + run sh -c "./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy attributes key assign --attribute $attr_id --key-id $key_system_id --json" + assert_success + + echo $SECRET_TEXT | ./otdfctl encrypt -o $OUT_TXT --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS -a "$missing_value_fqn" + + inspect_output=$(./otdfctl --host $HOST --tls-no-verify $WITH_CREDS inspect $OUTFILE_TXT) + policy_b64=$(echo "$inspect_output" | jq -r '.manifest.encryptionInformation.policy') + assert_not_equal "$policy_b64" "null" + assert_not_equal "$policy_b64" "" + run sh -c "printf '%s' \"$policy_b64\" | base64 -d" + assert_success + assert_output --partial "$missing_value_fqn" + assert_equal "$(echo "$inspect_output" | jq -r '.manifest.encryptionInformation.keyAccess | length')" "1" + assert_equal "$(echo "$inspect_output" | jq -r '.manifest.encryptionInformation.keyAccess[0].kid')" "$key_id" + assert_equal "$(echo "$inspect_output" | jq -r '.manifest.encryptionInformation.keyAccess[0].url')" "$kas_uri" + + run sh -c "./otdfctl --host $HOST $WITH_CREDS policy attributes key remove --attribute $attr_id --key-id $key_system_id" + assert_success + run sh -c "./otdfctl --host $HOST $WITH_CREDS policy attributes unsafe delete --id $attr_id --force" + assert_success + run sh -c "./otdfctl --host $HOST $WITH_CREDS policy kas-registry key unsafe delete --id $key_system_id --key-id $key_id --kas-uri $kas_uri --force" + assert_success + run sh -c "./otdfctl --host $HOST $WITH_CREDS policy kas-registry delete --id $kas_id --force" + assert_success +} + +@test "allow traversal uses attribute value mapping when value present" { + local attr_name="attr-allow-traversal-value-${RANDOM}" + local value_name="val-${RANDOM}" + local kas_name="kas-allow-traversal-value-${RANDOM}" + local kas_uri="https://kas-allow-traversal-value-${RANDOM}.example.com" + local def_key_id="def-key-${RANDOM}" + local val_key_id="val-key-${RANDOM}" + local ns_id="$NS_ID" + + if [[ -z "$ns_id" ]]; then + ns_id=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy attributes namespaces list --json | jq -r '.namespaces[] | select(.name=="testing-enc-dec.io") | .id' | head -n 1) + fi + if [[ -z "$ns_id" ]]; then + echo "Failed to resolve namespace id for testing-enc-dec.io" + return 1 + fi + + attr_output=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy attributes create --namespace "$ns_id" -n "$attr_name" -r HIERARCHY -v "$value_name" --allow-traversal --json) + attr_id=$(echo "$attr_output" | jq -r '.id') + value_id=$(echo "$attr_output" | jq -r '.values[0].id') + value_fqn=$(echo "$attr_output" | jq -r '.values[0].fqn') + + kas_output=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy kas-registry create --uri "$kas_uri" -n "$kas_name" --json) + kas_id=$(echo "$kas_output" | jq -r '.id') + + pem_b64=$(openssl genrsa 2048 2>/dev/null | openssl rsa -pubout 2>/dev/null | base64 | tr -d '\n') + def_key_output=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy kas-registry key create --kas "$kas_id" --key-id "$def_key_id" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "$pem_b64" --json) + def_key_system_id=$(echo "$def_key_output" | jq -r '.key.id') + val_key_output=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy kas-registry key create --kas "$kas_id" --key-id "$val_key_id" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "$pem_b64" --json) + val_key_system_id=$(echo "$val_key_output" | jq -r '.key.id') + + run sh -c "./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy attributes key assign --attribute $attr_id --key-id $def_key_system_id --json" + assert_success + run sh -c "./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy attributes values key assign --value $value_id --key-id $val_key_system_id --json" + assert_success + + echo $SECRET_TEXT | ./otdfctl encrypt -o $OUT_TXT --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS -a "$value_fqn" + + inspect_output=$(./otdfctl --host $HOST --tls-no-verify $WITH_CREDS inspect $OUTFILE_TXT) + assert_equal "$(echo "$inspect_output" | jq -r '.manifest.encryptionInformation.keyAccess | length')" "1" + assert_equal "$(echo "$inspect_output" | jq -r '.manifest.encryptionInformation.keyAccess[0].kid')" "$val_key_id" + assert_equal "$(echo "$inspect_output" | jq -r '.manifest.encryptionInformation.keyAccess[0].url')" "$kas_uri" + + run sh -c "./otdfctl --host $HOST $WITH_CREDS policy attributes values key remove --value $value_id --key-id $val_key_system_id" + assert_success + run sh -c "./otdfctl --host $HOST $WITH_CREDS policy attributes key remove --attribute $attr_id --key-id $def_key_system_id" + assert_success + run sh -c "./otdfctl --host $HOST $WITH_CREDS policy attributes unsafe delete --id $attr_id --force" + assert_success + run sh -c "./otdfctl --host $HOST $WITH_CREDS policy kas-registry key unsafe delete --id $def_key_system_id --key-id $def_key_id --kas-uri $kas_uri --force" + assert_success + run sh -c "./otdfctl --host $HOST $WITH_CREDS policy kas-registry key unsafe delete --id $val_key_system_id --key-id $val_key_id --kas-uri $kas_uri --force" + assert_success + run sh -c "./otdfctl --host $HOST $WITH_CREDS policy kas-registry delete --id $kas_id --force" + assert_success +} + +@test "allow traversal with inactive attribute value fails" { + local attr_name="attr-allow-traversal-inactive-${RANDOM}" + local value_name="val-inactive-${RANDOM}" + local ns_id="$NS_ID" + + if [[ -z "$ns_id" ]]; then + ns_id=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy attributes namespaces list --json | jq -r '.namespaces[] | select(.name=="testing-enc-dec.io") | .id' | head -n 1) + fi + if [[ -z "$ns_id" ]]; then + echo "Failed to resolve namespace id for testing-enc-dec.io" + return 1 + fi + + attr_output=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy attributes create --namespace "$ns_id" -n "$attr_name" -r HIERARCHY -v "$value_name" --allow-traversal --json) + attr_id=$(echo "$attr_output" | jq -r '.id') + value_id=$(echo "$attr_output" | jq -r '.values[0].id') + value_fqn=$(echo "$attr_output" | jq -r '.values[0].fqn') + + run sh -c "./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy attributes values deactivate --id $value_id --force" + assert_success + + run sh -c "echo \"$SECRET_TEXT\" | ./otdfctl encrypt -o $OUT_TXT --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS -a \"$value_fqn\"" + assert_failure + + run sh -c "./otdfctl --host $HOST $WITH_CREDS policy attributes unsafe delete --id $attr_id --force" + assert_success +} + +@test "roundtrip TDF3, assertions, stdin" { + echo $SECRET_TEXT | ./otdfctl encrypt -o $OUT_TXT --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS -a $FQN --with-assertions "$ASSERTIONS" + ./otdfctl decrypt --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS $OUTFILE_TXT | grep "$SECRET_TEXT" + ./otdfctl --host $HOST --tls-no-verify $WITH_CREDS inspect $OUTFILE_TXT + assertions_present=$(./otdfctl --host $HOST --tls-no-verify $WITH_CREDS inspect $OUTFILE_TXT | jq '.manifest.assertions[0].id') + [[ $assertions_present == "\"assertion1\"" ]] +} + +@test "roundtrip TDF3, assertions with HS256 keys and verification, file" { + ./otdfctl encrypt -o $OUTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS -a $FQN --with-assertions $SIGNED_ASSERTIONS_HS256 --tdf-type tdf3 $INFILE_GO_MOD + ./otdfctl decrypt -o $RESULTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --with-assertion-verification-keys $SIGNED_ASSERTION_VERIFICATON_HS256 --tdf-type tdf3 $OUTFILE_GO_MOD + diff $INFILE_GO_MOD $RESULTFILE_GO_MOD + ./otdfctl --host $HOST --tls-no-verify $WITH_CREDS inspect $OUTFILE_GO_MOD + assertions_present=$(./otdfctl --host $HOST --tls-no-verify $WITH_CREDS inspect $OUTFILE_GO_MOD | jq '.manifest.assertions[0].id') + [[ $assertions_present == "\"assertion1\"" ]] +} + +@test "roundtrip TDF3, assertions with RS256 keys and verification, file" { + ./otdfctl encrypt -o $OUTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS -a $FQN --with-assertions $SIGNED_ASSERTIONS_RS256 --tdf-type tdf3 $INFILE_GO_MOD + ./otdfctl decrypt -o $RESULTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --with-assertion-verification-keys $SIGNED_ASSERTION_VERIFICATON_RS256 --tdf-type tdf3 $OUTFILE_GO_MOD + diff $INFILE_GO_MOD $RESULTFILE_GO_MOD + ./otdfctl --host $HOST --tls-no-verify $WITH_CREDS inspect $OUTFILE_GO_MOD + assertions_present=$(./otdfctl --host $HOST --tls-no-verify $WITH_CREDS inspect $OUTFILE_GO_MOD | jq '.manifest.assertions[0].id') + [[ $assertions_present == "\"assertion1\"" ]] +} + +@test "roundtrip TDF3, with target version < 4.3.0" { + ./otdfctl encrypt -o $OUTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --tdf-type tdf3 --target-mode v4.2.2 $INFILE_GO_MOD + ./otdfctl decrypt -o $RESULTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --tdf-type tdf3 $OUTFILE_GO_MOD + diff $INFILE_GO_MOD $RESULTFILE_GO_MOD + + schema_version_present=$(./otdfctl --host $HOST --tls-no-verify $WITH_CREDS inspect $OUTFILE_GO_MOD | jq '.manifest | has("schemaVersion")') + [[ $schema_version_present == false ]] +} + +@test "roundtrip TDF3, with target version >= 4.3.0" { + ./otdfctl encrypt -o $OUTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --tdf-type tdf3 --target-mode v4.3.1 $INFILE_GO_MOD + ./otdfctl decrypt -o $RESULTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --tdf-type tdf3 $OUTFILE_GO_MOD + diff $INFILE_GO_MOD $RESULTFILE_GO_MOD + + schema_version_present=$(./otdfctl --host $HOST --tls-no-verify $WITH_CREDS inspect $OUTFILE_GO_MOD | jq '.manifest | has("schemaVersion")') + [[ $schema_version_present == true ]] +} + +@test "roundtrip TDF3, with allowlist containing platform kas" { + ./otdfctl encrypt -o $OUTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --tdf-type tdf3 $INFILE_GO_MOD + run sh -c "./otdfctl decrypt --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --tdf-type tdf3 --kas-allowlist http://localhost:8080/kas $OUTFILE_GO_MOD" + assert_success +} + +@test "roundtrip TDF3, with allowlist containing non existent kas (should fail)" { + ./otdfctl encrypt -o $OUTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --tdf-type tdf3 $INFILE_GO_MOD + run sh -c "./otdfctl decrypt --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --tdf-type tdf3 --kas-allowlist http://not-a-real-kas.com/kas $OUTFILE_GO_MOD" + assert_failure + assert_output --partial "KasAllowlist: kas url http://localhost:8080/kas is not allowed" +} + +@test "roundtrip TDF3, ignoring allowlist" { + ./otdfctl encrypt -o $OUTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --tdf-type tdf3 $INFILE_GO_MOD + run sh -c "./otdfctl decrypt --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --tdf-type tdf3 --kas-allowlist '*' $OUTFILE_GO_MOD" + assert_success + assert_output --partial "kasAllowlist is ignored" +} + +@test "roundtrip TDF3, not entitled to data, no required obligations returned" { + run sh -c "./otdfctl encrypt -o $OUTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS -a $ATTR_OBL_VAL_FQN $INFILE_GO_MOD" + assert_success + run sh -c "./otdfctl decrypt --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS $OUTFILE_GO_MOD" + assert_failure + refute_output --partial "required obligations" +} + +@test "roundtrip TDF3, entitled to data, required obligations returned" { + # Handle subject mapping + run sh -c "./otdfctl policy subject-mappings create --attribute-value-id $ATTR_OBL_VAL_ID --action read --subject-condition-set-new '[{\"conditionGroups\":[{\"conditions\":[{\"operator\":\"SUBJECT_MAPPING_OPERATOR_ENUM_IN\",\"subjectExternalValues\":[\"opentdf\"],\"subjectExternalSelectorValue\":\".clientId\"}], \"booleanOperator\":\"CONDITION_BOOLEAN_TYPE_ENUM_OR\"}]}]' --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS" + assert_success + + run sh -c "./otdfctl encrypt -o $OUTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS -a $ATTR_OBL_VAL_FQN $INFILE_GO_MOD" + assert_success + run sh -c "./otdfctl decrypt --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS $OUTFILE_GO_MOD" + assert_failure + assert_output --partial "required obligations: [$OBL_VAL_FQN]" +} diff --git a/otdfctl/e2e/kas-grants.bats b/otdfctl/e2e/kas-grants.bats new file mode 100755 index 0000000000..9c746a0ff3 --- /dev/null +++ b/otdfctl/e2e/kas-grants.bats @@ -0,0 +1,82 @@ +#!/usr/bin/env bats + +# Tests for KAS grants + +setup_file() { + export WITH_CREDS='--with-client-creds-file ./creds.json' + export HOST='--host http://localhost:8080' + + export KAS_URI="https://e2etestkas.com" + export KAS_ID=$(./otdfctl $HOST $WITH_CREDS policy kas-registry create --uri "$KAS_URI" --json | jq -r '.id') + export KAS_ID_FLAG="--kas-id $KAS_ID" + + export NS_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes namespaces create -n "testing-kasg.uk" --json | jq -r '.id') + ATTR=$(./otdfctl $HOST $WITH_CREDS policy attributes create -n "attr1" --json --rule ANY_OF --namespace "$NS_ID" -v "val1") + export ATTR_ID=$(echo $ATTR | jq -r '.id') + export VAL_ID=$(echo $ATTR | jq -r '.values[0].id') +} + +setup() { + load "${BATS_LIB_PATH}/bats-support/load.bash" + load "${BATS_LIB_PATH}/bats-assert/load.bash" + + # invoke binary with credentials + run_otdfctl_kasg() { + run sh -c "./otdfctl $HOST $WITH_CREDS policy kas-grants $*" + } +} + +teardown_file() { + ./otdfctl $HOST $WITH_CREDS policy attributes namespaces unsafe delete --id "$NS_ID" --force + ./otdfctl $HOST $WITH_CREDS policy kas-registry delete --id "$KAS_ID" --force + + # clear out all test env vars + unset HOST WITH_CREDS KAS_ID KAS_ID_FLAG KAS_URI NS_ID NS_ID_FLAG ATTR_ID ATTR_ID_FLAG VAL_ID VAL_ID_FLAG +} + +@test "unassign rejects more than one type of grant at once" { + export NS_ID_FLAG='--namespace-id 258e69b7-9e61-46e1-8fd6-b4ba00898ec2' + export ATTR_ID_FLAG='--attribute-id 258e69b7-9e61-46e1-8fd6-b4ba00898ec1' + export VAL_ID_FLAG='--value-id 258e69b7-9e61-46e1-8fd6-b4ba00898ec3' + + run_otdfctl_kasg unassign $ATTR_ID_FLAG $VAL_ID_FLAG $KAS_ID_FLAG + assert_failure + assert_output --partial "Must specify exactly one Attribute Namespace ID, Definition ID, or Value ID to unassign" + + run_otdfctl_kasg unassign $NS_ID_FLAG $VAL_ID_FLAG $KAS_ID_FLAG + assert_failure + assert_output --partial "Must specify exactly one Attribute Namespace ID, Definition ID, or Value ID to unassign" + + run_otdfctl_kasg unassign $ATTR_ID_FLAG $NS_ID_FLAG $KAS_ID_FLAG + assert_failure + assert_output --partial "Must specify exactly one Attribute Namespace ID, Definition ID, or Value ID to unassign" +} + +@test "assign grant prints warning" { + # assign the namespace a grant + export NS_ID_FLAG="--namespace-id $NS_ID" + + run_otdfctl_kasg assign "$NS_ID_FLAG" "$KAS_ID_FLAG" + assert_output --partial "Grants are now Key Mappings." + + run_otdfctl_kasg unassign "$NS_ID_FLAG" "$KAS_ID_FLAG" + assert_output --partial "Grants are now Key Mappings." +} + +@test "optional ID flag string error message" { + export NS_ID_FLAG='--namespace-id hello' + export ATTR_ID_FLAG='--attribute-id world' + export VAL_ID_FLAG='--value-id goodnight' + + run_otdfctl_kasg unassign $NS_ID_FLAG $KAS_ID_FLAG + assert_failure + assert_output --partial "Optional flag '--namespace-id' received value 'hello' and must be a valid UUID if used" + + run_otdfctl_kasg unassign $ATTR_ID_FLAG $KAS_ID_FLAG + assert_failure + assert_output --partial "Optional flag '--attribute-id' received value 'world' and must be a valid UUID if used" + + run_otdfctl_kasg unassign $VAL_ID_FLAG $KAS_ID_FLAG + assert_failure + assert_output --partial "Optional flag '--value-id' received value 'goodnight' and must be a valid UUID if used" +} diff --git a/otdfctl/e2e/kas-keys-mappings.bats b/otdfctl/e2e/kas-keys-mappings.bats new file mode 100644 index 0000000000..0eadced2a0 --- /dev/null +++ b/otdfctl/e2e/kas-keys-mappings.bats @@ -0,0 +1,246 @@ +#!/usr/bin/env bats + +# Tests listing key mappings + +load "${BATS_LIB_PATH}/bats-support/load.bash" +load "${BATS_LIB_PATH}/bats-assert/load.bash" +load "otdfctl-utils.sh" + +# Helper functions for otdfctl commands +run_otdfctl_key() { + run sh -c "./otdfctl policy kas-registry key $HOST $WITH_CREDS $*" +} + +run_otdfctl_kas_registry_create() { + run sh -c "./otdfctl policy kas-registry create $HOST $WITH_CREDS $*" +} + +run_otdfctl_namespace_create() { + run sh -c "./otdfctl policy attributes namespaces create $HOST $WITH_CREDS $*" +} + +run_otdfctl_attribute_create() { + run sh -c "./otdfctl policy attributes create $HOST $WITH_CREDS $*" +} + +run_otdfctl_value_create() { + run sh -c "./otdfctl policy attributes values create $HOST $WITH_CREDS $*" +} + +run_otdfctl_namespace_assign_key() { + run sh -c "./otdfctl policy attributes namespaces key assign $HOST $WITH_CREDS $*" +} + +run_otdfctl_attribute_assign_key() { + run sh -c "./otdfctl policy attributes key assign $HOST $WITH_CREDS $*" +} + +run_otdfctl_value_assign_key() { + run sh -c "./otdfctl policy attributes values key assign $HOST $WITH_CREDS $*" +} + +run_otdfctl_namespace_remove_key() { + run sh -c "./otdfctl policy attributes namespaces key remove $HOST $WITH_CREDS $*" +} + +run_otdfctl_attribute_remove_key() { + run sh -c "./otdfctl policy attributes key remove $HOST $WITH_CREDS $*" +} + +run_otdfctl_value_remove_key() { + run sh -c "./otdfctl policy attributes values key remove $HOST $WITH_CREDS $*" +} + +run_otdfctl_value_delete() { + run sh -c "./otdfctl policy attributes values unsafe delete --force $HOST $WITH_CREDS $*" +} + +run_otdfctl_attribute_delete() { + run sh -c "./otdfctl policy attributes unsafe delete --force $HOST $WITH_CREDS $*" +} + +run_otdfctl_namespace_delete() { + run sh -c "./otdfctl policy namespaces unsafe delete --force $HOST $WITH_CREDS $*" +} + +setup_file() { + export WITH_CREDS='--with-client-creds-file ./creds.json' + export HOST='--host http://localhost:8080' + export KAS_URI="https://test-kas-for-mappings.com" + export KAS_NAME="kas-registry-for-mappings-test" + # Generate valid public keys for different algorithms and base64 encode (single-line) + export PEM_B64_RSA_2048=$(openssl genrsa 2048 2>/dev/null | openssl rsa -pubout 2>/dev/null | base64 | tr -d '\n') + export PEM_B64_EC_P256=$(openssl ecparam -name prime256v1 -genkey 2>/dev/null | openssl ec -pubout 2>/dev/null | base64 | tr -d '\n') + export PEM_B64_RSA_4096=$(openssl genrsa 4096 2>/dev/null | openssl rsa -pubout 2>/dev/null | base64 | tr -d '\n') + + run_otdfctl_kas_registry_create --name $KAS_NAME --uri "$KAS_URI" --json + assert_success + export KAS_REGISTRY_ID=$(echo "$output" | jq -r '.id') + + # Create three keys + export KEY_ID_1=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_1}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64_RSA_2048}" --json + assert_success + export SYSTEM_KEY_ID_1=$(echo "$output" | jq -r '.key.id') + + export KEY_ID_2=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_2}" --algorithm "ec:secp256r1" --mode "public_key" --public-key-pem "${PEM_B64_EC_P256}" --json + assert_success + export SYSTEM_KEY_ID_2=$(echo "$output" | jq -r '.key.id') + + export KEY_ID_3=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_3}" --algorithm "rsa:4096" --mode "public_key" --public-key-pem "${PEM_B64_RSA_4096}" --json + assert_success + export SYSTEM_KEY_ID_3=$(echo "$output" | jq -r '.key.id') + + # Create a namespace, attribute, and value for testing assignments + export NAMESPACE_NAME="test-namespace-for-mappings.com" + run_otdfctl_namespace_create --name "${NAMESPACE_NAME}" --json + assert_success + export NAMESPACE_ID=$(echo "$output" | jq -r '.id') + + export ATTRIBUTE_NAME=$(generate_kas_name) + run_otdfctl_attribute_create --name "${ATTRIBUTE_NAME}" --namespace "${NAMESPACE_ID}" --rule ALL_OF --json + assert_success + export ATTRIBUTE_ID=$(echo "$output" | jq -r '.id') + + export VALUE_NAME=$(generate_kas_name) + run_otdfctl_value_create --value "${VALUE_NAME}" --attribute-id "${ATTRIBUTE_ID}" --json + assert_success + export VALUE_ID=$(echo "$output" | jq -r '.id') + + # Assign all three keys to the namespace, attribute, and value + run_otdfctl_namespace_assign_key --namespace "${NAMESPACE_ID}" --key-id "${SYSTEM_KEY_ID_1}" + assert_success + run_otdfctl_namespace_assign_key --namespace "${NAMESPACE_ID}" --key-id "${SYSTEM_KEY_ID_2}" + assert_success + run_otdfctl_namespace_assign_key --namespace "${NAMESPACE_ID}" --key-id "${SYSTEM_KEY_ID_3}" + assert_success + + run_otdfctl_attribute_assign_key --attribute "${ATTRIBUTE_ID}" --key-id "${SYSTEM_KEY_ID_1}" + assert_success + run_otdfctl_attribute_assign_key --attribute "${ATTRIBUTE_ID}" --key-id "${SYSTEM_KEY_ID_2}" + assert_success + run_otdfctl_attribute_assign_key --attribute "${ATTRIBUTE_ID}" --key-id "${SYSTEM_KEY_ID_3}" + assert_success + + run_otdfctl_value_assign_key --value "${VALUE_ID}" --key-id "${SYSTEM_KEY_ID_1}" + assert_success + run_otdfctl_value_assign_key --value "${VALUE_ID}" --key-id "${SYSTEM_KEY_ID_2}" + assert_success + run_otdfctl_value_assign_key --value "${VALUE_ID}" --key-id "${SYSTEM_KEY_ID_3}" + assert_success +} + +setup() { + # No setup specific to individual tests needed here currently + : # No-op +} + +teardown_file() { + # Unassign the keys + run_otdfctl_namespace_remove_key --namespace "${NAMESPACE_ID}" --key-id "${SYSTEM_KEY_ID_1}" + run_otdfctl_namespace_remove_key --namespace "${NAMESPACE_ID}" --key-id "${SYSTEM_KEY_ID_2}" + run_otdfctl_namespace_remove_key --namespace "${NAMESPACE_ID}" --key-id "${SYSTEM_KEY_ID_3}" + run_otdfctl_attribute_remove_key --attribute "${ATTRIBUTE_ID}" --key-id "${SYSTEM_KEY_ID_1}" + run_otdfctl_attribute_remove_key --attribute "${ATTRIBUTE_ID}" --key-id "${SYSTEM_KEY_ID_2}" + run_otdfctl_attribute_remove_key --attribute "${ATTRIBUTE_ID}" --key-id "${SYSTEM_KEY_ID_3}" + run_otdfctl_value_remove_key --value "${VALUE_ID}" --key-id "${SYSTEM_KEY_ID_1}" + run_otdfctl_value_remove_key --value "${VALUE_ID}" --key-id "${SYSTEM_KEY_ID_2}" + run_otdfctl_value_remove_key --value "${VALUE_ID}" --key-id "${SYSTEM_KEY_ID_3}" + + # Delete the value, attribute, and namespace + run_otdfctl_value_delete --id "${VALUE_ID}" + run_otdfctl_attribute_delete --id "${ATTRIBUTE_ID}" + run_otdfctl_namespace_delete --id "${NAMESPACE_ID}" + + delete_all_keys_in_kas "$KAS_REGISTRY_ID" + delete_kas_registry "$KAS_REGISTRY_ID" + + unset HOST WITH_CREDS KAS_REGISTRY_ID KAS_NAME KAS_URI PEM_B64 KEY_ID_1 SYSTEM_KEY_ID_1 KEY_ID_2 SYSTEM_KEY_ID_2 KEY_ID_3 SYSTEM_KEY_ID_3 NAMESPACE_ID NAMESPACE_NAME ATTRIBUTE_ID ATTRIBUTE_NAME VALUE_ID VALUE_NAME +} + +# Helper function to generate a unique key ID +generate_key_id() { + local length="${1:-8}" + + if [ ! -c /dev/urandom ]; then + echo "Error: /dev/urandom not found. Cannot generate random string." >&2 + return 1 + fi + key_id=$(LC_ALL=C tr /dev/null | head -c "${length}") + echo "$key_id" +} + +generate_kas_name() { + local length="${1:-6}" + + if [ ! -c /dev/urandom ]; then + echo "Error: /dev/urandom not found. Cannot generate random string." >&2 + return 1 + fi + kas_name=$(LC_ALL=C tr /dev/null | head -c "${length}") + echo "$kas_name" +} + +format_kas_name_as_uri() { + local input="$1" + echo "http://${input}.org" +} + +# Helper function to assert key mapping details +assert_key_mapping_details() { + local key_id="$1" + assert_equal "$(echo "$output" | jq -r '.key_mappings | length')" "1" + assert_equal "$(echo "$output" | jq -r '.key_mappings.[0].kid')" "${key_id}" + assert_equal "$(echo "$output" | jq -r '.key_mappings.[0].kas_uri')" "${KAS_URI}" + assert_equal "$(echo "$output" | jq -r '.key_mappings.[0].namespace_mappings | length')" "1" + assert_equal "$(echo "$output" | jq -r '.key_mappings.[0].attribute_mappings | length')" "1" + assert_equal "$(echo "$output" | jq -r '.key_mappings.[0].value_mappings | length')" "1" + assert_equal "$(echo "$output" | jq -r '.key_mappings.[0].namespace_mappings[0].id')" "${NAMESPACE_ID}" + assert_equal "$(echo "$output" | jq -r '.key_mappings.[0].attribute_mappings[0].id')" "${ATTRIBUTE_ID}" + assert_equal "$(echo "$output" | jq -r '.key_mappings.[0].value_mappings[0].id')" "${VALUE_ID}" +} + +@test "kas-keys-mappings: list key mappings for a specific key by kas id" { + run_otdfctl_key list-mappings --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_1}" --json + assert_success + assert_key_mapping_details "${KEY_ID_1}" +} + +@test "kas-keys-mappings: list key mappings for a specific key by kas name" { + run_otdfctl_key list-mappings --kas "${KAS_NAME}" --key-id "${KEY_ID_1}" --json + assert_success + assert_key_mapping_details "${KEY_ID_1}" +} + +@test "kas-keys-mappings: list key mappings for a specific key by kas uri" { + run_otdfctl_key list-mappings --kas "${KAS_URI}" --key-id "${KEY_ID_1}" --json + assert_success + assert_key_mapping_details "${KEY_ID_1}" +} + +@test "kas-keys-mappings: list key mappings with pagination" { + run_otdfctl_key list-mappings --json --limit 1 --offset 0 + assert_success + assert_equal "$(echo "$output" | jq -r '.key_mappings | length')" "1" + assert_not_equal "$(echo "$output" | jq -r '.key_mappings[0].kid')" "null" + assert [ "$(echo "$output" | jq -r '.pagination.total')" -ge 3 ] + assert_equal "$(echo "$output" | jq -r '.pagination.next_offset')" "1" +} + +@test "kas-keys-mappings: list key mappings - required together are missing" { + run_otdfctl_key list-mappings --key-id "nonexistent-key" --json + assert_failure + assert_output --partial "--kas" + + run_otdfctl_key list-mappings --kas "${KAS_NAME}" --json + assert_failure + assert_output --partial "--kas" +} + +@test "kas-keys-mappings: list key mappings - mutually exclusive flags" { + run_otdfctl_key list-mappings --kas "${KAS_NAME}" --key-id "nonexistent-key" --id "${KEY_ID_1}" --json + assert_failure + assert_output --partial "Error: if any flags in the group [kas id] are set none of the others can be; [id kas] were all set" +} diff --git a/otdfctl/e2e/kas-keys.bats b/otdfctl/e2e/kas-keys.bats new file mode 100644 index 0000000000..5f1c5d550f --- /dev/null +++ b/otdfctl/e2e/kas-keys.bats @@ -0,0 +1,1211 @@ +#!/usr/bin/env bats + +load "${BATS_LIB_PATH}/bats-support/load.bash" +load "${BATS_LIB_PATH}/bats-assert/load.bash" +load "otdfctl-utils.sh" + +run_otdfctl_kas_registry_create() { + run sh -c "./otdfctl policy kas-registry create $HOST $WITH_CREDS $*" +} + +run_otdfctl_provider_create() { + run sh -c "./otdfctl policy keymanagement provider create $HOST $WITH_CREDS $*" +} + +setup_file() { + export WITH_CREDS='--with-client-creds-file ./creds.json' + export HOST='--host http://localhost:8080' + # This command is not a 'kas-registry key' subcommand, so it won't use run_otdfctl_key + export KAS_URI="https://test-kas-with-keys.com" + export KAS_NAME="kas-registry-for-keys-test" + + run_otdfctl_kas_registry_create --name $KAS_NAME --uri "$KAS_URI" --json + assert_success + export KAS_REGISTRY_ID=$(echo "$output" | jq -r '.id') + + if [ "$RUN_EXPERIMENTAL_TESTS" == "true" ]; then + run_otdfctl_provider_create --name "test-provider-config-kas-keys" --config '{}' --json + assert_success + export PC_ID=$(echo "$output" | jq -r '.id') + fi + export WRAPPING_KEY=$(openssl rand -hex 32) + # Generate valid public keys and base64 encode (single-line) + export PEM_B64_RSA=$(openssl genrsa 2048 2>/dev/null | openssl rsa -pubout 2>/dev/null | base64 | tr -d '\n') + export PEM_B64_EC_P256=$(openssl ecparam -name prime256v1 -genkey 2>/dev/null | openssl ec -pubout 2>/dev/null | base64 | tr -d '\n') + export PEM_B64_RSA_4096=$(openssl genrsa 4096 2>/dev/null | openssl rsa -pubout 2>/dev/null | base64 | tr -d '\n') + export PEM_B64=${PEM_B64_RSA} +} + +setup() { + # No setup specific to individual tests needed here currently + : # No-op +} + + +teardown_file() { + delete_all_keys_in_kas "$KAS_REGISTRY_ID" + delete_kas_registry "$KAS_REGISTRY_ID" + if [ -n "$PC_ID" ]; then + delete_provider_config "$PC_ID" + fi + + unset HOST WITH_CREDS KAS_REGISTRY_ID KAS_NAME KAS_URI PEM_B64 WRAPPING_KEY PC_ID +} + +# Helper function to generate a unique key ID +generate_key_id() { + local length="${1:-8}" + + # Check if /dev/urandom is available + if [ ! -c /dev/urandom ]; then + echo "Error: /dev/urandom not found. Cannot generate random string." >&2 + return 1 + fi + key_id=$(LC_ALL=C tr /dev/null | head -c "${length}") + echo "$key_id" +} + +generate_kas_name() { + local length="${1:-6}" + + # Check if /dev/urandom is available + if [ ! -c /dev/urandom ]; then + echo "Error: /dev/urandom not found. Cannot generate random string." >&2 + return 1 + fi + kas_name=$(LC_ALL=C tr /dev/null | head -c "${length}") + echo "$kas_name" +} + +format_kas_name_as_uri() { + local input="$1" + echo "http://${input}.org" +} + +@test "kas-keys: create key (local mode, rsa:2048)" { + KEY_ID=$(generate_key_id) + # For local mode, a public key is generated by otdfctl. + # Its exact value cannot be known before running the command without replicating the key generation logic. + # Thus, we assert its presence and that it\'s a non-empty base64 encoded string. This is the intended assertion. + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm rsa:2048 --mode local --wrapping-key-id wrapping-key-1 --wrapping-key "${WRAPPING_KEY}" --json + assert_success + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "1" # rsa:2048 + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "1" # local + assert_equal "$(echo "$output" | jq -r .key.key_status)" "1" # active (assuming default) + assert_equal "$(echo "$output" | jq -r .key.legacy)" "null" # False should be null + # Assert public_key_ctx.pem is present and not empty + assert_not_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "" + # Assert private_key_ctx for local mode + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx.key_id)" "wrapping-key-1" + assert_not_equal "$(echo "$output" | jq -r .key.private_key_ctx.wrapped_key)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.private_key_ctx.wrapped_key)" "" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.created_at)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "null" +} + +@test "kas-keys: create key (local mode, ec:secp256r1)" { + KEY_ID=$(generate_key_id) + # For local mode, a public key is generated by otdfctl. + # Its exact value cannot be known before running the command without replicating the key generation logic. + # Thus, we assert its presence and that it\'s a non-empty base64 encoded string. This is the intended assertion. + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "ec:secp256r1" --mode "local" --wrapping-key-id "wrapping-key-1" --wrapping-key "${WRAPPING_KEY}" --json + assert_success + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "3" # ec:secp256r1 + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "1" # local + assert_equal "$(echo "$output" | jq -r .key.key_status)" "1" # active + assert_equal "$(echo "$output" | jq -r .key.legacy)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "" + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx.key_id)" "wrapping-key-1" + assert_not_equal "$(echo "$output" | jq -r .key.private_key_ctx.wrapped_key)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.private_key_ctx.wrapped_key)" "" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.created_at)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "null" +} + +@test "kas-keys: create key (public_key mode)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "1" # rsa:2048 + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "4" # public_key + assert_equal "$(echo "$output" | jq -r .key.key_status)" "1" # active + assert_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "${PEM_B64}" + assert_equal "$(echo "$output" | jq -r .key.legacy)" "null" + # Assert private_key_ctx is null or not present for public_key mode + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.created_at)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "null" +} + +@test "kas-keys: create key (remote mode)" { + if [ "$RUN_EXPERIMENTAL_TESTS" != "true" ]; then + skip "Skipping experimental test" + fi + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "remote" --public-key-pem "${PEM_B64}" --provider-config-id "${PC_ID}" --wrapping-key-id "wrapping-key-remote" --json + assert_success + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "1" # rsa:2048 + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "3" # remote + assert_equal "$(echo "$output" | jq -r .key.key_status)" "1" # active + assert_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "${PEM_B64}" + assert_equal "$(echo "$output" | jq -r .key.legacy)" "null" + # Assert private_key_ctx is not what it is for local mode, but check its key_id as per previous logic + # Based on kas-keys.go, remote mode sets privateKeyCtx.key-id = wrapping-key-id + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx.key_id)" "wrapping-key-remote" + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx.wrapped_key)" "null" # wrapped_key should not be set for remote + assert_not_equal "$(echo "$output" | jq -r .key.metadata.created_at)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "null" +} + +@test "kas-keys: create key (provider mode)" { + if [ "$RUN_EXPERIMENTAL_TESTS" != "true" ]; then + skip "Skipping experimental test" + fi + KEY_ID=$(generate_key_id) + WRAPPING_KEY_ID="wrapping-key-for-provider" + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "provider" --provider-config-id "${PC_ID}" --wrapping-key-id "${WRAPPING_KEY_ID}" --public-key-pem "${PEM_B64}" --private-key-pem "${PEM_B64}" --json + assert_success + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "1" # rsa:2048 + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "2" # public_key + assert_equal "$(echo "$output" | jq -r .key.key_status)" "1" # active + assert_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "${PEM_B64}" + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx.wrapped_key)" "${PEM_B64}" + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx.key_id)" "${WRAPPING_KEY_ID}" + assert_equal "$(echo "$output" | jq -r .key.legacy)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.created_at)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "null" +} + +@test "kas-keys: create key with labels" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --label "env=dev" --label "owner=test" --json + assert_success + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "1" # rsa:2048 + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "4" # public_key + assert_equal "$(echo "$output" | jq -r .key.key_status)" "1" # active + assert_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "${PEM_B64}" + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx)" "null" + assert_equal "$(echo "$output" | jq -r .key.legacy)" "null" + assert_equal "$(echo "$output" | jq -r '.key.metadata.labels."env"')" "dev" + assert_equal "$(echo "$output" | jq -r '.key.metadata.labels."owner"')" "test" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.created_at)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "null" +} + +@test "kas-keys: create key (missing key-id)" { + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --algorithm "rsa:2048" --mode "local" --wrapping-key-id "wrapping-key-1" --wrapping-key "${WRAPPING_KEY}" + assert_failure + assert_output --partial "Flag '--key-id' is required" +} + +@test "kas-keys: create key (missing algorithm)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --mode "local" --wrapping-key-id "wrapping-key-1" --wrapping-key "${WRAPPING_KEY}" + assert_failure + assert_output --partial "Flag '--algorithm' is required" +} + +@test "kas-keys: create key (missing mode)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --wrapping-key-id "wrapping-key-1" --wrapping-key "${WRAPPING_KEY}" + assert_failure + assert_output --partial "Flag '--mode' is required" +} + +@test "kas-keys: create key (local mode, missing wrapping-key-id)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "local" --wrapping-key "${WRAPPING_KEY}" + assert_failure + assert_output --partial "wrapping-key-id is required for mode local" +} + +@test "kas-keys: create key (local mode, missing wrapping-key)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "local" --wrapping-key-id "wrapping-key-1" + assert_failure + assert_output --partial "Flag '--wrapping-key' is required" +} + +@test "kas-keys: create key (public_key mode, missing pem)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "public_key" + assert_failure + # The error message might vary based on how --public-key-pem is validated if missing. + # Assuming it's caught by the CLI framework or the command logic. + assert_output --partial "Flag '--public-key-pem' is required" +} + +@test "kas-keys: create key (remote mode, missing pem)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "remote" --provider-config-id "pc-1" --wrapping-key-id "wk-1" + assert_failure + assert_output --partial "Flag '--public-key-pem' is required" +} + +@test "kas-keys: create key (remote mode, missing provider-config-id)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "remote" --public-key-pem "${PEM_B64}" --wrapping-key-id "wk-1" + assert_failure + assert_output --partial "Flag '--provider-config-id' is required" +} + +@test "kas-keys: create key (remote mode, missing wrapping-key-id)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "remote" --public-key-pem "${PEM_B64}" --provider-config-id "pc-1" + assert_failure + assert_output --partial "wrapping-key-id is required for mode remote" +} + +@test "kas-keys: create key (provider mode, missing wrapping-key-id)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "provider" --provider-config-id "pc-1" + assert_failure + assert_output --partial "wrapping-key-id is required for mode provider" +} + +@test "kas-keys: create key (provider mode, missing provider-config-id)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "provider" --wrapping-key-id "wk-1" + assert_failure + assert_output --partial "Flag '--provider-config-id' is required" +} + +@test "kas-keys: create key (remote mode, pem not base64)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "remote" --public-key-pem "not-base64-value" --provider-config-id "pc-1" --wrapping-key-id "wk-1" + assert_failure + assert_output --partial "pem must be base64 encoded" +} + +@test "kas-keys: create key (public_key mode, pem not base64)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "not-base64-value" + assert_failure + assert_output --partial "pem must be base64 encoded" +} + +@test "kas-keys: create key (public_key mode, invalid PEM content)" { + KEY_ID=$(generate_key_id) + # base64 of a non-PEM string + BAD_PEM_B64=$(echo "not a pem" | base64 | tr -d '\n') + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${BAD_PEM_B64}" + assert_failure + assert_output --partial "invalid public key pem" +} + +@test "kas-keys: create key (public_key mode, EC key with RSA algorithm)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64_EC_P256}" + assert_failure + assert_output --partial "invalid public key pem" +} + +@test "kas-keys: create key (missing kas identifier)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" + assert_failure + assert_output --partial "Flag '--kas' is required" +} + +@test "kas-keys: create key (using kasName)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_NAME}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "1" # rsa:2048 + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "4" # public_key + assert_equal "$(echo "$output" | jq -r .key.key_status)" "1" # active + assert_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "${PEM_B64}" + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx)" "null" + assert_equal "$(echo "$output" | jq -r .key.legacy)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.created_at)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "null" +} + +@test "kas-keys: create key (using kasUri)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_URI}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "1" # rsa:2048 + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "4" # public_key + assert_equal "$(echo "$output" | jq -r .key.key_status)" "1" # active + assert_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "${PEM_B64}" + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx)" "null" + assert_equal "$(echo "$output" | jq -r .key.legacy)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.created_at)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "null" +} + +@test "kas-keys: create key (invalid algorithm value)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "invalid-algorithm-value" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_failure + assert_output --partial "invalid algorithm" +} + +@test "kas-keys: create key (invalid mode value)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "invalid-mode-value" --public-key-pem "${PEM_B64}" --json + assert_failure + assert_output --partial "invalid mode" +} + +@test "kas-keys: create key (duplicate key-id)" { + KEY_ID_DUPLICATE=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_DUPLICATE}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_DUPLICATE}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_failure + assert_output --partial "Failed to create kas key" +} + +@test "kas-keys: create key (invalid kas identifier)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "invalid-kas-id" --key-id "${KEY_ID}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_failure + assert_output --partial "Failed to resolve KAS identifier 'invalid-kas-id': not_found: resource not found" +} + +@test "kas-keys: create key (invalid hex encoded wrapping-key)" { + KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID}" --algorithm "ec:secp256r1" --mode "local" --wrapping-key-id "wrapping-key-1" --wrapping-key "not-hex-encoded" --json + assert_failure + + assert_output --partial "wrapping-key must be hex encoded" +} + +@test "kas-keys: get key by system ID" { + KEY_ID_GET=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_GET}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + CREATED_KEY_SYSTEM_ID=$(echo "$output" | jq -r .key.id) + + run_otdfctl_key get --key "${CREATED_KEY_SYSTEM_ID}" --json + assert_success + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" + assert_equal "$(echo "$output" | jq -r .key.id)" "${CREATED_KEY_SYSTEM_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID_GET}" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "1" # rsa:2048 + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "4" # public_key + assert_equal "$(echo "$output" | jq -r .key.key_status)" "1" # active + assert_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "${PEM_B64}" + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx)" "null" + assert_equal "$(echo "$output" | jq -r .key.legacy)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.created_at)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "null" +} + +@test "kas-keys: get key by user key-id and kasId" { + KEY_ID_GET_USER=$(generate_key_id) + # Using ec:secp256r1 and public_key mode for variety + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_GET_USER}" --algorithm "ec:secp256r1" --mode "public_key" --public-key-pem "${PEM_B64_EC_P256}" --json + assert_success + local created_key_system_id_for_get=$(echo "$output" | jq -r .key.id) + + run_otdfctl_key get --key "${KEY_ID_GET_USER}" --kas "${KAS_REGISTRY_ID}" --json + assert_success + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" + assert_equal "$(echo "$output" | jq -r .key.id)" "${created_key_system_id_for_get}" + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID_GET_USER}" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "3" # ec:secp256r1 + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "4" # public_key + assert_equal "$(echo "$output" | jq -r .key.key_status)" "1" # active + assert_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "${PEM_B64_EC_P256}" + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx)" "null" + assert_equal "$(echo "$output" | jq -r .key.legacy)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.created_at)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "null" +} + +@test "kas-keys: get key by user key-id and kasName" { + KEY_ID_GET_USER_kas=$(generate_key_id) + run_otdfctl_key create --kas "kas-registry-for-keys-test" --key-id "${KEY_ID_GET_USER_kas}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + local created_key_system_id_for_kas_get=$(echo "$output" | jq -r .key.id) + + run_otdfctl_key get --key "${KEY_ID_GET_USER_kas}" --kas "kas-registry-for-keys-test" --json + assert_success + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" + assert_equal "$(echo "$output" | jq -r .key.id)" "${created_key_system_id_for_kas_get}" + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID_GET_USER_kas}" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "1" # rsa:2048 + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "4" # public_key + assert_equal "$(echo "$output" | jq -r .key.key_status)" "1" # active + assert_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "${PEM_B64}" + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx)" "null" + assert_equal "$(echo "$output" | jq -r .key.legacy)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.created_at)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "null" +} + +@test "kas-keys: get key by user key-id and kasUri" { + KEY_ID_GET_USER_kas=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_URI}" --key-id "${KEY_ID_GET_USER_kas}" --algorithm "ec:secp256r1" --mode "public_key" --public-key-pem "${PEM_B64_EC_P256}" --json + assert_success + local created_key_system_id_for_kas_get=$(echo "$output" | jq -r .key.id) + + run_otdfctl_key get --key "${KEY_ID_GET_USER_kas}" --kas "${KAS_URI}" --json + assert_success + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" # Should resolve to the same KAS + assert_equal "$(echo "$output" | jq -r .key.id)" "${created_key_system_id_for_kas_get}" + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID_GET_USER_kas}" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "3" # ec:secp256r1 + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "4" # public_key + assert_equal "$(echo "$output" | jq -r .key.key_status)" "1" # active + assert_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "${PEM_B64_EC_P256}" + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx)" "null" + assert_equal "$(echo "$output" | jq -r .key.legacy)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.created_at)" "null" + assert_not_equal "$(echo "$output" | jq -r .key.metadata.updated_at)" "null" +} + +@test "kas-keys: get key (failure: only key-id, missing KAS identifier)" { + KEY_ID_FAIL_GET=$(generate_key_id) + # Create a key first so it potentially exists, though the failure should be due to missing KAS context for the get + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_FAIL_GET}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + + run_otdfctl_key get --key "${KEY_ID_FAIL_GET}" --json + assert_failure + # Error message might vary, but it should indicate an issue with resolving the key or missing parameters + assert_output --partial "Flag '--kas' is required" # Or a more specific error about missing KAS identifier +} + +@test "kas-keys: get key (failure: only kas, missing key-id or system id)" { + run_otdfctl_key get --kas "${KAS_REGISTRY_ID}" --json + assert_failure + assert_output --partial "Flag '--key' is required" +} + +@test "kas-keys: get key (not found by system ID)" { + run_otdfctl_key get --key "39af808f-6cac-403f-90d7-6b88e865860d" --json + assert_failure + assert_output --partial "Failed to get kas key" # Error should indicate not found or similar +} + +@test "kas-keys: get key (not found by user key-id and kas)" { + run_otdfctl_key get --key "non-existent-key" --kas "${KAS_REGISTRY_ID}" --json + assert_failure + assert_output --partial "Failed to get kas key" # Error should indicate not found or similar +} + +@test "kas-keys: update key labels (add)" { + KEY_ID_UPDATE_LABEL=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_UPDATE_LABEL}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --label "initial=true" --json + assert_success + UPDATE_KEY_LABEL_SYSTEM_ID=$(echo "$output" | jq -r .key.id) + local initial_created_at_seconds=$(echo "$output" | jq -r .key.metadata.created_at.seconds) + local initial_updated_at_seconds=$(echo "$output" | jq -r .key.metadata.updated_at.seconds) + + run_otdfctl_key update --id "${UPDATE_KEY_LABEL_SYSTEM_ID}" --label "added=true" --json + assert_success + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" + assert_equal "$(echo "$output" | jq -r .key.id)" "${UPDATE_KEY_LABEL_SYSTEM_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID_UPDATE_LABEL}" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "1" # rsa:2048 + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "4" # public_key + assert_equal "$(echo "$output" | jq -r .key.key_status)" "1" # active (should not change) + assert_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "${PEM_B64}" + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx)" "null" + assert_equal "$(echo "$output" | jq -r .key.legacy)" "null" + assert_equal "$(echo "$output" | jq -r '.key.metadata.labels."initial"')" "true" + assert_equal "$(echo "$output" | jq -r '.key.metadata.labels."added"')" "true" + assert_equal "$(echo "$output" | jq -r .key.metadata.created_at.seconds)" "${initial_created_at_seconds}" # created_at should not change + + # Verify with a subsequent get + run_otdfctl_key get --key "${UPDATE_KEY_LABEL_SYSTEM_ID}" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.key.metadata.labels."initial"')" "true" + assert_equal "$(echo "$output" | jq -r '.key.metadata.labels."added"')" "true" +} + +@test "kas-keys: update key labels (replace)" { + KEY_ID_UPDATE_LABEL_REPLACE=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_UPDATE_LABEL_REPLACE}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --label "initial=true" --json + assert_success + UPDATE_KEY_LABEL_REPLACE_SYSTEM_ID=$(echo "$output" | jq -r .key.id) + local initial_created_at_replace_seconds=$(echo "$output" | jq -r .key.metadata.created_at.seconds) + + run_otdfctl_key update --id "${UPDATE_KEY_LABEL_REPLACE_SYSTEM_ID}" --label "replaced=true" --force-replace-labels --json + assert_success + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" + assert_equal "$(echo "$output" | jq -r .key.id)" "${UPDATE_KEY_LABEL_REPLACE_SYSTEM_ID}" + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID_UPDATE_LABEL_REPLACE}" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "1" # rsa:2048 + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "4" # public_key + assert_equal "$(echo "$output" | jq -r .key.key_status)" "1" # active + assert_equal "$(echo "$output" | jq -r '.key.metadata.labels."replaced"')" "true" + assert_equal "$(echo "$output" | jq -r '.key.metadata.labels."initial" // "null"')" "null" + assert_equal "$(echo "$output" | jq -r .key.metadata.created_at.seconds)" "${initial_created_at_replace_seconds}" + + # Verify with a subsequent get + run_otdfctl_key get --key "${UPDATE_KEY_LABEL_REPLACE_SYSTEM_ID}" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.key.metadata.labels."replaced"')" "true" + assert_equal "$(echo "$output" | jq -r '.key.metadata.labels."initial" // "null"')" "null" +} + +@test "kas-keys: update key (not found)" { + run_otdfctl_key update --id "39af808f-6cac-403f-90d7-6b88e865860d" --json + assert_failure + assert_output --partial "Failed to update kas key" +} + +@test "kas-keys: update key (missing id)" { + run_otdfctl_key update --json + assert_failure + assert_output --partial "ERROR Flag '--id' is required" +} + +# LIST Tests +@test "kas-keys: list keys (default limit and offset)" { + # Create a few keys to ensure there\'s something to list and to check structure + KEY_ID_LIST_1=$(generate_key_id) + + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_LIST_1}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64_RSA}" --json + assert_success + local key1_system_id=$(echo "$output" | jq -r .key.id) + + KEY_ID_LIST_2=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KEY_ID_LIST_2}" --algorithm "ec:secp256r1" --mode "public_key" --public-key-pem "${PEM_B64_EC_P256}" --json + assert_success + local key2_system_id=$(echo "$output" | jq -r .key.id) + + run_otdfctl_key list --json + assert_success + + # For key1: + assert_equal "$(echo "$output" | jq -r --arg id "${key1_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.id')" "${key1_system_id}" + assert_equal "$(echo "$output" | jq -r --arg id "${key1_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.key_id')" "${KEY_ID_LIST_1}" + assert_equal "$(echo "$output" | jq -r --arg id "${key1_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.key_algorithm')" "1" + assert_equal "$(echo "$output" | jq -r --arg id "${key1_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.key_mode')" "4" + assert_equal "$(echo "$output" | jq -r --arg id "${key1_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.key_status')" "1" + assert_equal "$(echo "$output" | jq -r --arg id "${key1_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.legacy')" "null" + assert_equal "$(echo "$output" | jq -r --arg id "${key1_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.public_key_ctx.pem')" "${PEM_B64_RSA}" + assert_equal "$(echo "$output" | jq -r --arg id "${key1_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.private_key_ctx')" "null" + assert_not_equal "$(echo "$output" | jq -r --arg id "${key1_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.metadata.created_at')" "null" + assert_not_equal "$(echo "$output" | jq -r --arg id "${key1_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.metadata.updated_at')" "null" + + # For key2: + assert_equal "$(echo "$output" | jq -r --arg id "${key2_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.id')" "${key2_system_id}" + assert_equal "$(echo "$output" | jq -r --arg id "${key2_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.key_id')" "${KEY_ID_LIST_2}" + assert_equal "$(echo "$output" | jq -r --arg id "${key2_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.key_algorithm')" "3" + assert_equal "$(echo "$output" | jq -r --arg id "${key2_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.key_mode')" "4" + assert_equal "$(echo "$output" | jq -r --arg id "${key2_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.legacy')" "null" + assert_equal "$(echo "$output" | jq -r --arg id "${key2_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.key_status')" "1" + assert_equal "$(echo "$output" | jq -r --arg id "${key2_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.public_key_ctx.pem')" "${PEM_B64_EC_P256}" + assert_equal "$(echo "$output" | jq -r --arg id "${key2_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.private_key_ctx')" "null" + assert_not_equal "$(echo "$output" | jq -r --arg id "${key2_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.metadata.created_at')" "null" + assert_not_equal "$(echo "$output" | jq -r --arg id "${key2_system_id}" '.kas_keys[] | select(.key.id == $id) | .key.metadata.updated_at')" "null" +} + +@test "kas-keys: list keys (pagination with limit and offset)" { + KAS_NAME_LIST=$(generate_kas_name) + KAS_URI_LIST=$(format_kas_name_as_uri "${KAS_NAME_LIST}") + KAS_ID_LIST=$(./otdfctl $HOST $WITH_CREDS policy kas-registry create --name "$KAS_NAME_LIST" --uri "$KAS_URI_LIST" --json | jq -r '.id') + assert_not_equal "$KAS_ID_LIST" "" + + # Create a known set of keys for pagination testing + local key_p1_id=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_ID_LIST}" --key-id "${key_p1_id}" --algorithm "rsa:4096" --mode "public_key" --public-key-pem "${PEM_B64_RSA_4096}" --json + assert_success + local key_p1_sys_id=$(echo "$output" | jq -r .key.id) + + local key_p2_id=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_ID_LIST}" --key-id "${key_p2_id}" --algorithm "ec:secp256r1" --mode "public_key" --public-key-pem "${PEM_B64_EC_P256}" --json + assert_success + local key_p2_sys_id=$(echo "$output" | jq -r .key.id) + + local key_p3_id=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_ID_LIST}" --key-id "${key_p3_id}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64_RSA}" --json + assert_success + local key_p3_sys_id=$(echo "$output" | jq -r .key.id) + + # Test: limit 1, offset 0 - should get the first key (order dependent on server, so we check for one of them) + run_otdfctl_key list --kas "${KAS_ID_LIST}" --limit 1 --offset 0 --json + assert_success + assert_equal "$(echo "$output" | jq '.kas_keys | length')" "1" + local found_id_limit1_offset0=$(echo "$output" | jq -r '.kas_keys[0].key.id') + + # Test: limit 1, offset 1 - should get the second key + run_otdfctl_key list --kas "${KAS_ID_LIST}" --limit 1 --offset 1 --json + assert_success + assert_equal "$(echo "$output" | jq '.kas_keys | length')" "1" + local found_id_limit1_offset1=$(echo "$output" | jq -r '.kas_keys[0].key.id') + assert_not_equal "${found_id_limit1_offset1}" "${found_id_limit1_offset0}" + + run_otdfctl_key list --kas "${KAS_ID_LIST}" --limit 3 --offset 0 --json # Fetch up to 3 + assert_success + local count_limit3_offset0=$(echo "$output" | jq '.kas_keys | length') + assert_equal "${count_limit3_offset0}" 3 + assert_equal "$(echo "$output" | jq -r --arg id "${key_p1_sys_id}" '.kas_keys[] | select(.key.id == $id) | .key.id')" "${key_p1_sys_id}" + assert_equal "$(echo "$output" | jq -r --arg id "${key_p2_sys_id}" '.kas_keys[] | select(.key.id == $id) | .key.id')" "${key_p2_sys_id}" + assert_equal "$(echo "$output" | jq -r --arg id "${key_p3_sys_id}" '.kas_keys[] | select(.key.id == $id) | .key.id')" "${key_p3_sys_id}" + + # Test: limit 1, offset (large number, e.g., 100) - should get 0 keys + run_otdfctl_key list --kas "${KAS_ID_LIST}" --limit 1 --offset 100 --json + assert_success + assert_equal "$(echo $output | jq '.kas_keys | length')" "0" + + delete_all_keys_in_kas "$KAS_ID_LIST" + delete_kas_registry "$KAS_ID_LIST" +} + +@test "kas-keys: list keys (filter by algorithm rsa:2048)" { + KAS_NAME_LIST=$(generate_kas_name) + KAS_URI_LIST=$(format_kas_name_as_uri "${KAS_NAME_LIST}") + KAS_ID_LIST=$(./otdfctl $HOST $WITH_CREDS policy kas-registry create --name "$KAS_NAME_LIST" --uri "$KAS_URI_LIST" --json | jq -r '.id') + assert_not_equal "$KAS_ID_LIST" "" + + # Ensure at least one rsa:2048 key exists for this KAS + KEY_ID_LIST_RSA=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_ID_LIST}" --key-id "${KEY_ID_LIST_RSA}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64_RSA}" --json + assert_success + local rsa_key_sys_id=$(echo "$output" | jq -r .key.id) + + # Ensure at least one non-rsa:2048 key exists for this KAS to test filtering + KEY_ID_LIST_EC=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_ID_LIST}" --key-id "${KEY_ID_LIST_EC}" --algorithm "ec:secp256r1" --mode "public_key" --public-key-pem "${PEM_B64_EC_P256}" --json + assert_success + local ec_key_sys_id=$(echo "$output" | jq -r .key.id) + + run_otdfctl_key list --kas "${KAS_ID_LIST}" --algorithm "rsa:2048" --json + assert_success + # Every key in the list should be rsa:2048 + # And our specific RSA key should be present + assert_equal "$(echo "$output" | jq -r --arg id "${rsa_key_sys_id}" '.kas_keys[] | select(.key.id == $id) | .key.id')" "${rsa_key_sys_id}" + assert_equal "$(echo "$output" | jq -r --arg id "${rsa_key_sys_id}" '.kas_keys[] | select(.key.id == $id) | .key.key_id')" "${KEY_ID_LIST_RSA}" + assert_equal "$(echo "$output" | jq -r --arg id "${rsa_key_sys_id}" '.kas_keys[] | select(.key.id == $id) | .key.key_algorithm')" "1" + assert_equal "$(echo "$output" | jq -r --arg id "${rsa_key_sys_id}" '.kas_keys[] | select(.key.id == $id) | .key.key_mode')" "4" + assert_equal "$(echo "$output" | jq -r --arg id "${rsa_key_sys_id}" '.kas_keys[] | select(.key.id == $id) | .key.key_status')" "1" + assert_equal "$(echo "$output" | jq -r --arg id "${rsa_key_sys_id}" '.kas_keys[] | select(.key.id == $id) | .key.public_key_ctx.pem')" "${PEM_B64_RSA}" + assert_equal "$(echo "$output" | jq -r --arg id "${rsa_key_sys_id}" '.kas_keys[] | select(.key.id == $id) | .key.private_key_ctx')" "null" + assert_not_equal "$(echo "$output" | jq -r --arg id "${rsa_key_sys_id}" '.kas_keys[] | select(.key.id == $id) | .key.metadata.created_at')" "null" + assert_not_equal "$(echo "$output" | jq -r --arg id "${rsa_key_sys_id}" '.kas_keys[] | select(.key.id == $id) | .key.metadata.updated_at')" "null" + + # Check that all listed keys have key_algorithm 1 (algorithmORITHM_RSA_2048) + local count_non_rsa=$(echo "$output" | jq '[.kas_keys[] | select(.key.key_algorithm != 1)] | length') + assert_equal "$count_non_rsa" "0" + + delete_all_keys_in_kas "$KAS_ID_LIST" + delete_kas_registry "$KAS_ID_LIST" +} + +@test "kas-keys: list keys (filter by kas)" { + KAS_NAME_LIST=$(generate_kas_name) + KAS_URI_LIST=$(format_kas_name_as_uri "${KAS_NAME_LIST}") + KAS_ID_LIST=$(./otdfctl $HOST $WITH_CREDS policy kas-registry create --name "$KAS_NAME_LIST" --uri "$KAS_URI_LIST" --json | jq -r '.id') + assert_not_equal "$KAS_ID_LIST" "" + + KEY_ID_LIST_KAS_FILTER=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_ID_LIST}" --key-id "${KEY_ID_LIST_KAS_FILTER}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + local kas_filter_key_sys_id=$(echo "$output" | jq -r .key.id) + + # List keys for the new KAS + run_otdfctl_key list --kas "${KAS_ID_LIST}" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].kas_id')" "${KAS_ID_LIST}" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.id')" "${kas_filter_key_sys_id}" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.key_id')" "${KEY_ID_LIST_KAS_FILTER}" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.key_algorithm')" "1" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.key_mode')" "4" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.key_status')" "1" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.legacy')" "null" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.public_key_ctx.pem')" "${PEM_B64}" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.private_key_ctx')" "null" + assert_not_equal "$(echo "$output" | jq -r '.kas_keys[0].key.metadata.created_at')" "null" + assert_not_equal "$(echo "$output" | jq -r '.kas_keys[0].key.metadata.updated_at')" "null" + assert_equal "$(echo "$output" | jq '.kas_keys | length')" "1" + + # List keys for the default KAS_REGISTRY_ID and ensure the new key is not present + run_otdfctl_key list --kas "${KAS_REGISTRY_ID}" --json + assert_success + assert_equal "$(echo "$output" | jq -r --arg id "${kas_filter_key_sys_id}" '[.kas_keys[] | select(.key.id == $id)] | length')" "0" + + delete_all_keys_in_kas "$KAS_ID_LIST" + delete_kas_registry "$KAS_ID_LIST" +} + +@test "kas-keys: list keys (filter by kasName)" { + KAS_NAME_LIST=$(generate_kas_name) + echo "DEBUG: KAS_NAME_LIST: ${KAS_NAME_LIST}" >&2 + KAS_URI_LIST=$(format_kas_name_as_uri "${KAS_NAME_LIST}") + KAS_ID_LIST=$(./otdfctl $HOST $WITH_CREDS policy kas-registry create --name "$KAS_NAME_LIST" --uri "$KAS_URI_LIST" --json | jq -r '.id') + assert_not_equal "$KAS_ID_LIST" "" + + KEY_ID_LIST_KAS_NAME_FILTER=$(generate_key_id) + local lower_kas_name=$(echo "${KAS_NAME_LIST}" | tr '[:upper:]' '[:lower:]') + run_otdfctl_key create --kas "${lower_kas_name}" --key-id "${KEY_ID_LIST_KAS_NAME_FILTER}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + local kas_name_filter_key_sys_id=$(echo "$output" | jq -r .key.id) + + run_otdfctl_key list --kas "${lower_kas_name}" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].kas_id')" "${KAS_ID_LIST}" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.id')" "${kas_name_filter_key_sys_id}" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.key_id')" "${KEY_ID_LIST_KAS_NAME_FILTER}" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.key_algorithm')" "1" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.key_mode')" "4" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.key_status')" "1" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.public_key_ctx.pem')" "${PEM_B64}" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.private_key_ctx')" "null" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.legacy')" "null" + assert_not_equal "$(echo "$output" | jq -r '.kas_keys[0].key.metadata.created_at')" "null" + assert_not_equal "$(echo "$output" | jq -r '.kas_keys[0].key.metadata.updated_at')" "null" + + delete_all_keys_in_kas "$KAS_ID_LIST" + delete_kas_registry "$KAS_ID_LIST" +} + +@test "kas-keys: list keys (filter by kasUri)" { + # This command is not a 'kas-registry key' subcommand, so it won't use run_otdfctl_key + KAS_NAME_LIST=$(generate_kas_name) + KAS_URI_LIST=$(format_kas_name_as_uri "${KAS_NAME_LIST}") + KAS_ID_LIST=$(./otdfctl $HOST $WITH_CREDS policy kas-registry create --name "$KAS_NAME_LIST" --uri "$KAS_URI_LIST" --json | jq -r '.id') + assert_not_equal "$KAS_ID_LIST" "" + + KEY_ID_LIST_KAS_URI_FILTER=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_URI_LIST}" --key-id "${KEY_ID_LIST_KAS_URI_FILTER}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + local kas_uri_filter_key_sys_id=$(echo "$output" | jq -r .key.id) + + run_otdfctl_key list --kas "${KAS_URI_LIST}" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].kas_id')" "${KAS_ID_LIST}" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.id')" "${kas_uri_filter_key_sys_id}" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.key_id')" "${KEY_ID_LIST_KAS_URI_FILTER}" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.key_algorithm')" "1" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.key_mode')" "4" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.key_status')" "1" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.legacy')" "null" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.public_key_ctx.pem')" "${PEM_B64}" + + delete_all_keys_in_kas "$KAS_ID_LIST" + delete_kas_registry "$KAS_ID_LIST" +} + + +@test "kas-keys: list legacy keys" { + KAS_NAME_LIST=$(generate_kas_name) + KAS_URI_LIST=$(format_kas_name_as_uri "${KAS_NAME_LIST}") + KAS_ID_LIST=$(./otdfctl $HOST $WITH_CREDS policy kas-registry create --name "$KAS_NAME_LIST" --uri "$KAS_URI_LIST" --json | jq -r '.id') + assert_not_equal "$KAS_ID_LIST" "" + + NON_LEGACY_KEY_ID="imported-key-$(generate_key_id)" + run_otdfctl_key import --key-id "${NON_LEGACY_KEY_ID}" \ + --algorithm "rsa:2048" \ + --kas "${KAS_ID_LIST}" \ + --wrapping-key-id "test-wrapping-key" \ + --wrapping-key "${WRAPPING_KEY}" \ + --public-key-pem "${PEM_B64}" \ + --private-key-pem "${PEM_B64}" \ + --legacy false \ + --json + + # Create a key that should be returned when legacy=true + KEY_ID_LEGACY=$(generate_key_id) + run_otdfctl_key import --key-id "${KEY_ID_LEGACY}" \ + --algorithm "rsa:2048" \ + --kas "${KAS_ID_LIST}" \ + --wrapping-key-id "test-wrapping-key-legacy" \ + --wrapping-key "${WRAPPING_KEY}" \ + --public-key-pem "${PEM_B64}" \ + --private-key-pem "${PEM_B64}" \ + --legacy true \ + --json + assert_success + + # List keys with legacy=true + run_otdfctl_key list --legacy true --kas "${KAS_ID_LIST}" --json + assert_success + assert_equal "$(echo "$output" | jq '.kas_keys | length')" "1" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.key_id')" "${KEY_ID_LEGACY}" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.legacy')" "true" + + run_otdfctl_key list --legacy false --kas "${KAS_ID_LIST}" --json + assert_success + assert_equal "$(echo "$output" | jq '.kas_keys | length')" "1" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.key_id')" "${NON_LEGACY_KEY_ID}" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].key.legacy')" "null" + + run_otdfctl_key list --kas "${KAS_ID_LIST}" --json + assert_success + assert_equal "$(echo "$output" | jq '.kas_keys | length')" "2" + assert_equal "$(echo "$output" | jq -r --arg id "${NON_LEGACY_KEY_ID}" '.kas_keys[] | select(.key.key_id == $id) | .key.key_id')" "${NON_LEGACY_KEY_ID}" + assert_equal "$(echo "$output" | jq -r --arg id "${KEY_ID_LEGACY}" '.kas_keys[] | select(.key.key_id == $id) | .key.key_id')" "${KEY_ID_LEGACY}" + + delete_all_keys_in_kas "$KAS_ID_LIST" + delete_kas_registry "$KAS_ID_LIST" +} + +@test "kas-keys: list keys (invalid algorithm)" { + run_otdfctl_key list --algorithm "invalid-algorithm" --json + assert_failure + assert_output --partial "Invalid algorithm" +} + +@test "kas-keys: list keys (legacy=invalid)" { + run_otdfctl_key list --legacy invalid --json + assert_failure + assert_output --partial "Invalid legacy flag" +} + +@test "kas-keys: rotate key" { + # Create a key first + OLD_KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${OLD_KEY_ID}" --algorithm "rsa:2048" --mode "local" --wrapping-key-id "wrapping-key-1" --wrapping-key "${WRAPPING_KEY}" --json + assert_success + OLD_KEY_SYSTEM_ID=$(echo "$output" | jq -r .key.id) + + # Rotate the key + NEW_KEY_ID=$(generate_key_id) + run_otdfctl_key rotate --key "${OLD_KEY_SYSTEM_ID}" --key-id "${NEW_KEY_ID}" --algorithm "rsa:2048" --mode "local" --wrapping-key-id "wrapping-key-2" --wrapping-key "${WRAPPING_KEY}" --json + assert_success + + # Verify the new key in kas_key section + NEW_KEY_SYSTEM_ID=$(echo "$output" | jq -r .kas_key.key.id) + assert_not_equal "${OLD_KEY_SYSTEM_ID}" "${NEW_KEY_SYSTEM_ID}" + assert_equal "$(echo "$output" | jq -r .kas_key.key.key_id)" "${NEW_KEY_ID}" + assert_equal "$(echo "$output" | jq -r .kas_key.key.key_algorithm)" "1" # rsa:2048 + assert_equal "$(echo "$output" | jq -r .kas_key.key.key_mode)" "1" # local + assert_equal "$(echo "$output" | jq -r .kas_key.key.key_status)" "1" # active (new key should be active) + assert_not_equal "$(echo "$output" | jq -r .kas_key.key.public_key_ctx.pem)" "null" + assert_not_equal "$(echo "$output" | jq -r .kas_key.key.public_key_ctx.pem)" "" + assert_equal "$(echo "$output" | jq -r .kas_key.key.private_key_ctx.key_id)" "wrapping-key-2" + assert_not_equal "$(echo "$output" | jq -r .kas_key.key.private_key_ctx.wrapped_key)" "null" + assert_not_equal "$(echo "$output" | jq -r .kas_key.key.private_key_ctx.wrapped_key)" "" + assert_not_equal "$(echo "$output" | jq -r .kas_key.key.metadata.created_at)" "null" + assert_not_equal "$(echo "$output" | jq -r .kas_key.key.metadata.updated_at)" "null" + + # Verify the old rotated key in rotated_resources section + assert_equal "$(echo "$output" | jq -r .rotated_resources.rotated_out_key.key.id)" "${OLD_KEY_SYSTEM_ID}" + assert_equal "$(echo "$output" | jq -r .rotated_resources.rotated_out_key.key.key_id)" "${OLD_KEY_ID}" + assert_equal "$(echo "$output" | jq -r .rotated_resources.rotated_out_key.key.key_status)" "2" # rotated (old key should be marked as rotated) +} + +@test "kas-keys: rotate key (missing key)" { + run_otdfctl_key rotate --key-id "new-key-id" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" + assert_failure + assert_output --partial "Flag '--key' is required" +} + +@test "kas-keys: rotate key (missing key-id)" { + run_otdfctl_key rotate --key "old-key-id" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" + assert_failure + assert_output --partial "Flag '--key-id' is required" +} + +@test "kas-keys: rotate key (missing algorithm)" { + run_otdfctl_key rotate --key "old-key-id" --key-id "new-key-id" --mode "public_key" --public-key-pem "${PEM_B64}" + assert_failure + assert_output --partial "Flag '--algorithm' is required" +} + +@test "kas-keys: rotate key (missing mode)" { + run_otdfctl_key rotate --key "old-key-id" --key-id "new-key-id" --algorithm "rsa:2048" --public-key-pem "${PEM_B64}" + assert_failure + assert_output --partial "Flag '--mode' is required" +} + +@test "kas-keys: rotate key (local mode, missing wrapping-key-id)" { + run_otdfctl_key rotate --key "old-key-id" --key-id "new-key-id" --algorithm "rsa:2048" --mode "local" --wrapping-key "${WRAPPING_KEY}" + assert_failure + assert_output --partial "wrapping-key-id is required for mode local" +} + +@test "kas-keys: rotate key (local mode, missing wrapping-key)" { + run_otdfctl_key rotate --key "old-key-id" --key-id "new-key-id" --algorithm "rsa:2048" --mode "local" --wrapping-key-id "wrapping-key-1" + assert_failure + assert_output --partial "Flag '--wrapping-key' is required" +} + +@test "kas-keys: rotate key (public_key mode, missing public-key-pem)" { + run_otdfctl_key rotate --key "old-key-id" --key-id "new-key-id" --algorithm "rsa:2048" --mode "public_key" + assert_failure + assert_output --partial "Flag '--public-key-pem' is required" +} + +@test "kas-keys: rotate key (remote mode, missing provider-config-id)" { + run_otdfctl_key rotate --key "old-key-id" --key-id "new-key-id" --algorithm "rsa:2048" --mode "remote" --public-key-pem "${PEM_B64}" --wrapping-key-id "wk-1" + assert_failure + assert_output --partial "Flag '--provider-config-id' is required" +} + +@test "kas-keys: rotate key (invalid algorithm)" { + # Create a key first that we can try to rotate + OLD_KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${OLD_KEY_ID}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + + # Try to rotate with invalid algorithm + NEW_KEY_ID=$(generate_key_id) + run_otdfctl_key rotate --key "${OLD_KEY_ID}" --key-id "${NEW_KEY_ID}" --algorithm "invalid-algorithm" --mode "public_key" --public-key-pem "${PEM_B64}" + assert_failure + assert_output --partial "invalid algorithm" +} + +@test "kas-keys: rotate key (invalid mode)" { + # Create a key first that we can try to rotate + OLD_KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${OLD_KEY_ID}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + + # Try to rotate with invalid mode + NEW_KEY_ID=$(generate_key_id) + run_otdfctl_key rotate --key "${OLD_KEY_ID}" --key-id "${NEW_KEY_ID}" --algorithm "rsa:2048" --mode "invalid-mode" --public-key-pem "${PEM_B64}" + assert_failure + assert_output --partial "invalid mode" +} + +@test "kas-keys: rotate key (invalid hex encoded wrapping-key)" { + # Create a key first that we can try to rotate + OLD_KEY_ID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${OLD_KEY_ID}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + + # Try to rotate with invalid wrapping-key + NEW_KEY_ID=$(generate_key_id) + run_otdfctl_key rotate --key "${OLD_KEY_ID}" --key-id "${NEW_KEY_ID}" --algorithm "rsa:2048" --mode "local" --public-key-pem "${PEM_B64}" --wrapping-key "not-hex-encoded" --wrapping-key-id "wrapping-key-1" + assert_failure + assert_output --partial "wrapping-key must be hex encoded" +} + +@test "kas-keys: import key successful" { + KEY_ID="imported-key-$(generate_key_id)" + + run_otdfctl_key import --key-id "${KEY_ID}" \ + --algorithm "rsa:2048" \ + --kas "${KAS_REGISTRY_ID}" \ + --wrapping-key-id "test-wrapping-key" \ + --wrapping-key "${WRAPPING_KEY}" \ + --public-key-pem "${PEM_B64}" \ + --private-key-pem "${PEM_B64}" \ + --json + assert_success + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID}" + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" + assert_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "${PEM_B64}" + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx.key_id)" "test-wrapping-key" + assert_not_equal "$(echo "$output" | jq -r .key.private_key_ctx.wrapped_key)" "null" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "1" + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "1" + assert_equal "$(echo "$output" | jq -r .key.legacy)" "null" +} + + +@test "kas-keys: import key successful (legacy=true)" { + KEY_ID="imported-key-$(generate_key_id)" + + run_otdfctl_key import --key-id "${KEY_ID}" \ + --algorithm "rsa:2048" \ + --kas "${KAS_REGISTRY_ID}" \ + --wrapping-key-id "test-wrapping-key" \ + --wrapping-key "${WRAPPING_KEY}" \ + --public-key-pem "${PEM_B64}" \ + --private-key-pem "${PEM_B64}" \ + --legacy true \ + --json + assert_success + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID}" + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" + assert_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "${PEM_B64}" + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx.key_id)" "test-wrapping-key" + assert_not_equal "$(echo "$output" | jq -r .key.private_key_ctx.wrapped_key)" "null" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "1" + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "1" + assert_equal "$(echo "$output" | jq -r .key.legacy)" "true" +} + +@test "kas-keys: import key successful (legacy=false)" { + KEY_ID="imported-key-$(generate_key_id)" + + run_otdfctl_key import --key-id "${KEY_ID}" \ + --algorithm "rsa:2048" \ + --kas "${KAS_REGISTRY_ID}" \ + --wrapping-key-id "test-wrapping-key" \ + --wrapping-key "${WRAPPING_KEY}" \ + --public-key-pem "${PEM_B64}" \ + --private-key-pem "${PEM_B64}" \ + --legacy false \ + --json + assert_success + + assert_equal "$(echo "$output" | jq -r .key.key_id)" "${KEY_ID}" + assert_equal "$(echo "$output" | jq -r .kas_id)" "${KAS_REGISTRY_ID}" + assert_equal "$(echo "$output" | jq -r .key.public_key_ctx.pem)" "${PEM_B64}" + assert_equal "$(echo "$output" | jq -r .key.private_key_ctx.key_id)" "test-wrapping-key" + assert_not_equal "$(echo "$output" | jq -r .key.private_key_ctx.wrapped_key)" "null" + assert_equal "$(echo "$output" | jq -r .key.key_algorithm)" "1" + assert_equal "$(echo "$output" | jq -r .key.key_mode)" "1" + assert_equal "$(echo "$output" | jq -r .key.legacy)" "null" +} + +@test "kas-keys: import key failure (legacy=invalid)" { + KEY_ID="imported-key-$(generate_key_id)" + + run_otdfctl_key import --key-id "${KEY_ID}" \ + --algorithm "rsa:2048" \ + --kas "${KAS_REGISTRY_ID}" \ + --wrapping-key-id "test-wrapping-key" \ + --wrapping-key "${WRAPPING_KEY}" \ + --public-key-pem "${PEM_B64}" \ + --private-key-pem "${PEM_B64}" \ + --legacy invalid \ + --json + assert_failure + assert_output --partial "Invalid legacy flag" +} + +@test "kas-keys: import key failure - missing required private key" { + KEY_ID="import-fail-$(generate_key_id)" + + run_otdfctl_key import --key-id "${KEY_ID}" \ + --algorithm "rsa:2048" \ + --kas "${KAS_REGISTRY_ID}" \ + --wrapping-key-id "test-wrapping-key" \ + --wrapping-key "${WRAPPING_KEY}" \ + --public-key-pem "${PEM_B64}" + + assert_failure + assert_output --partial "'--private-key-pem' is required" +} + +@test "kas-keys: import key failure - invalid wrapping key" { + KEY_ID="import-fail-$(generate_key_id)" + + run_otdfctl_key import --key-id "${KEY_ID}" \ + --algorithm "rsa:2048" \ + --kas "${KAS_REGISTRY_ID}" \ + --wrapping-key-id "test-wrapping-key" \ + --wrapping-key "not-a-valid-hex-string" \ + --public-key-pem "${PEM_B64}" \ + --private-key-pem "${PEM_B64}" + + assert_failure + assert_output --partial "wrapping-key must be hex encoded" +} + +@test "kas-keys: import key failure - invalid public key PEM" { + KEY_ID="import-fail-$(generate_key_id)" + + run_otdfctl_key import --key-id "${KEY_ID}" \ + --algorithm "rsa:2048" \ + --kas "${KAS_REGISTRY_ID}" \ + --wrapping-key-id "test-wrapping-key" \ + --wrapping-key "${WRAPPING_KEY}" \ + --public-key-pem "not-base64-encoded" \ + --private-key-pem "${PEM_B64}" + + assert_failure + assert_output --partial "public-key-pem must be base64 encoded" +} + +@test "kas-keys: import key failure - invalid private key PEM" { + KEY_ID="import-fail-$(generate_key_id)" + + run_otdfctl_key import --key-id "${KEY_ID}" \ + --algorithm "rsa:2048" \ + --kas "${KAS_REGISTRY_ID}" \ + --wrapping-key-id "test-wrapping-key" \ + --wrapping-key "${WRAPPING_KEY}" \ + --public-key-pem "${PEM_B64}" \ + --private-key-pem "not-base64-encoded" + + assert_failure + assert_output --partial "private-key-pem must be base64 encoded" +} + +@test "kas-keys: import key failure - invalid algorithm" { + KEY_ID="import-fail-$(generate_key_id)" + + run_otdfctl_key import --key-id "${KEY_ID}" \ + --algorithm "invalid-algorithm" \ + --kas "${KAS_REGISTRY_ID}" \ + --wrapping-key-id "test-wrapping-key" \ + --wrapping-key "${WRAPPING_KEY}" \ + --public-key-pem "${PEM_B64}" \ + --private-key-pem "${PEM_B64}" + + assert_failure + assert_output --partial "Invalid algorithm" +} + +@test "kas-keys: import key failure - missing wrapping key ID" { + KEY_ID="import-fail-$(generate_key_id)" + + run_otdfctl_key import --key-id "${KEY_ID}" \ + --algorithm "rsa:2048" \ + --kas "${KAS_REGISTRY_ID}" \ + --wrapping-key "${WRAPPING_KEY}" \ + --public-key-pem "${PEM_B64}" \ + --private-key-pem "${PEM_B64}" + + assert_failure + assert_output --partial "'--wrapping-key-id' is required" +} + +@test "kas-keys: import key failure - missing wrapping key" { + KEY_ID="import-fail-$(generate_key_id)" + + run_otdfctl_key import --key-id "${KEY_ID}" \ + --algorithm "rsa:2048" \ + --kas "${KAS_REGISTRY_ID}" \ + --wrapping-key-id "test-wrapping-key" \ + --public-key-pem "${PEM_B64}" \ + --private-key-pem "${PEM_B64}" + + assert_failure + assert_output --partial "'--wrapping-key' is required" +} + +@test "kas-keys: delete key" { + KID=$(generate_key_id) + run_otdfctl_key create --kas "${KAS_REGISTRY_ID}" --key-id "${KID}" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json + assert_success + CREATED_KEY_SYSTEM_ID=$(echo "$output" | jq -r .key.id) + + + run_otdfctl_key unsafe delete --id "${CREATED_KEY_SYSTEM_ID}" --key-id "${KID}" --kas-uri ${KAS_URI} --json --force + assert_success + + run_otdfctl_key get --key "${CREATED_KEY_SYSTEM_ID}" --json + assert_failure + assert_output --partial "Failed to get kas key" +} + +@test "kas-keys: delete key failure - (missing id)" { + run_otdfctl_key unsafe delete --kas-uri "a-uri" --key-id "key" --force + assert_failure + assert_output --partial "Flag '--id' is required" +} + +@test "kas-keys: delete key failure - (missing key-id)" { + run_otdfctl_key unsafe delete --id "ded32e6d-9fec-4a4c-a391-13158c52e5f2" --kas-uri "a-uri" --force + assert_failure + assert_output --partial "Flag '--key-id' is required" +} + +@test "kas-keys: delete key failure - (missing kas-uri)" { + run_otdfctl_key unsafe delete --id "ded32e6d-9fec-4a4c-a391-13158c52e5f2" --key-id "kid" --force + assert_failure + assert_output --partial "Flag '--kas-uri' is required" +} diff --git a/otdfctl/e2e/kas-registry.bats b/otdfctl/e2e/kas-registry.bats new file mode 100755 index 0000000000..e3e4e99cb8 --- /dev/null +++ b/otdfctl/e2e/kas-registry.bats @@ -0,0 +1,158 @@ +#!/usr/bin/env bats + +# Tests for kas registry + +setup_file() { + export CREDSFILE=creds.json + echo -n '{"clientId":"opentdf","clientSecret":"secret"}' >$CREDSFILE + export WITH_CREDS="--with-client-creds-file $CREDSFILE" + export HOST='--host http://localhost:8080' + export DEBUG_LEVEL="--log-level debug" +} + +setup() { + load "${BATS_LIB_PATH}/bats-support/load.bash" + load "${BATS_LIB_PATH}/bats-assert/load.bash" + + # invoke binary with credentials + run_otdfctl_kasr() { + run sh -c "./otdfctl policy kas-registry $HOST $WITH_CREDS $*" + } +} + +teardown() { + ID=$(echo "$CREATED" | jq -r '.id') + run_otdfctl_kasr delete --id "$ID" --force +} + +@test "create KAS registration with invalid URI - fails" { + BAD_URIS=( + "no-scheme.co" + "localhost" + "http://example.com:abc" + "https ://example.com" + ) + + for URI in "${BAD_URIS[@]}"; do + run_otdfctl_kasr create --uri "$URI" + assert_failure + assert_output --partial "Failed to create Registered KAS" + assert_output --partial "uri: " + done +} + +@test "create KAS registration with duplicate URI - fails" { + URI="https://testing-duplication.io" + run_otdfctl_kasr create --uri "$URI" + assert_success + export CREATED="$output" + run_otdfctl_kasr create --uri "$URI" + assert_failure + assert_output --partial "Failed to create Registered KAS entry" + assert_output --partial "already_exists" +} + +@test "create KAS registration with duplicate name - fails" { + NAME="duplicate_name_kas" + run_otdfctl_kasr create --uri "https://testing-duplication.name.io" -n "$NAME" + assert_success + run_otdfctl_kasr create --uri "https://testing-duplication.name.net" -n "$NAME" + assert_failure + assert_output --partial "Failed to create Registered KAS entry" + assert_output --partial "already_exists" +} + +@test "create KAS registration with invalid name - fails" { + URI="http://creating.kas.invalid.name/kas" + BAD_NAMES=( + "-bad-name" + "bad-name-" + "_bad_name" + "bad_name_" + "name@with!special#chars" + "$(printf 'a%.0s' {1..254})" # Generates a string of 254 'a' characters + ) + + for NAME in "${BAD_NAMES[@]}"; do + echo "testing $NAME" + run_otdfctl_kasr create --uri "$URI" -n "$NAME" + assert_failure + assert_output --partial "Failed to create Registered KAS" + assert_output --partial "name: " + done +} + +@test "update registered KAS" { + URI="https://testing-update.net" + NAME="new-kas-testing-update" + export CREATED=$(./otdfctl $HOST $DEBUG_LEVEL $WITH_CREDS policy kas-registry create --uri "$URI" -n "$NAME" --json) + ID=$(echo "$CREATED" | jq -r '.id') + run_otdfctl_kasr update --id "$ID" -u "https://newuri.com" -n "newer-name" --json + assert_output --partial "$ID" + assert_output --partial "https://newuri.com" + assert_output --partial "newer-name" + refute_output --partial "$NAME" + refute_output --partial "$URI" +} + +@test "update registered KAS with invalid URI - fails" { + export CREATED=$(./otdfctl $HOST $DEBUG_LEVEL $WITH_CREDS policy kas-registry create --uri "https://bad-update.uri.kas" --json) + ID=$(echo "$CREATED" | jq -r '.id') + BAD_URIS=( + "no-scheme.co" + "localhost" + "http://example.com:abc" + "https ://example.com" + ) + + for URI in "${BAD_URIS[@]}"; do + run_otdfctl_kasr update -i "$ID" --uri "$URI" + assert_failure + assert_output --partial "$ID" + assert_output --partial "Failed to update Registered KAS entry" + assert_output --partial "uri: " + done +} + +@test "update registered KAS with invalid name - fails" { + export CREATED=$(./otdfctl $HOST $DEBUG_LEVEL $WITH_CREDS policy kas-registry create --uri "https://bad-update.name.kas" --json) + ID=$(echo "$CREATED" | jq -r '.id') + BAD_NAMES=( + "-bad-name" + "bad-name-" + "_bad_name" + "bad_name_" + "name@with!special#chars" + "$(printf 'a%.0s' {1..254})" # Generates a string of 254 'a' characters + ) + + for NAME in "${BAD_NAMES[@]}"; do + run_otdfctl_kasr update --id "$ID" --name "$NAME" + assert_failure + assert_output --partial "Failed to update Registered KAS" + assert_output --partial "name: " + done +} + +@test "list registered KASes" { + URI="https://testing-list.io" + NAME="listed-kas" + export CREATED=$(./otdfctl $HOST $DEBUG_LEVEL $WITH_CREDS policy kas-registry create --uri "$URI" -n "$NAME" --json) + ID=$(echo "$CREATED" | jq -r '.id') + run_otdfctl_kasr list --json + assert_output --partial "$ID" + assert_output --partial "uri" + assert_output --partial "$URI" + assert_output --partial "name" + assert_output --partial "$NAME" + + run_otdfctl_kasr list + assert_output --partial "Total" + assert_line --regexp "Current Offset.*0" + + run_otdfctl_kasr list --json + assert_success + assert_not_equal $(echo "$output" | jq -r ".pagination") "null" + total=$(echo "$output" | jq -r ".pagination.total") + [[ $total -ge 1 ]] +} diff --git a/otdfctl/e2e/key-base.bats b/otdfctl/e2e/key-base.bats new file mode 100644 index 0000000000..5267177dd0 --- /dev/null +++ b/otdfctl/e2e/key-base.bats @@ -0,0 +1,168 @@ +#!/usr/bin/env bats + +# NEEDS TO RUN AFTER encrypt-decrypt.bats +load "${BATS_LIB_PATH}/bats-support/load.bash" +load "${BATS_LIB_PATH}/bats-assert/load.bash" +load "otdfctl-utils.sh" + +setup_file() { + export WITH_CREDS='--with-client-creds-file ./creds.json' + export HOST='--host http://localhost:8080' + + # Create a KAS registry entry for testing base keys + export KAS_NAME_BASE_KEY_TEST="kas-registry-for-base-key-tests" + export KAS_URI_BASE_KEY_TEST="https://test-kas-for-base-keys.com" + export KAS_REGISTRY_ID_BASE_KEY_TEST=$(./otdfctl $HOST $WITH_CREDS policy kas-registry create --name "${KAS_NAME_BASE_KEY_TEST}" --uri "${KAS_URI_BASE_KEY_TEST}" --json | jq -r '.id') + + # Create a regular KAS key to be set as a base key + # This key will be used by the 'set' command tests + export REGULAR_KEY_ID_FOR_BASE_TEST="regular-key-for-base-$(date +%s)" + export WRAPPING_KEY="9453b4d7cc55cf27926ae8f98a9d5aa159d51b7a4d478e440271ab261792a2bd" + export KAS_KEY_SYSTEM_ID=$(./otdfctl $HOST $WITH_CREDS policy kas-registry key create --kas "${KAS_REGISTRY_ID_BASE_KEY_TEST}" --key-id "${REGULAR_KEY_ID_FOR_BASE_TEST}" --algorithm rsa:2048 --mode local --wrapping-key "${WRAPPING_KEY}" --wrapping-key-id "wrapping-key-id" --json | jq -r '.key.id') +} + +setup() { + # invoke binary with credentials for base key commands + run_otdfctl_base_key() { + run sh -c "./otdfctl policy kas-registry key base $HOST $WITH_CREDS $*" + } +} + +teardown_file() { + # Note: A key will be present still, due to a FK where we do + # not allow keys to be deleted if they are currently set as the base key. + delete_all_keys_in_kas "$KAS_REGISTRY_ID_BASE_KEY_TEST" + + unset HOST WITH_CREDS KAS_REGISTRY_ID_BASE_KEY_TEST KAS_NAME_BASE_KEY_TEST KAS_URI_BASE_KEY_TEST REGULAR_KEY_ID_FOR_BASE_TEST WRAPPING_KEY KAS_KEY_SYSTEM_ID +} + +# --- get base key tests --- + +@test "base-key: get (initially no base key should be set for a new KAS)" { + run_otdfctl_base_key get + assert_failure # Expecting failure or specific message indicating no base key + assert_output --partial "No base key found" # Or similar error message +} + +# --- set base key tests --- + +@test "base-key: set by --key (uuid)" { + run_otdfctl_base_key set --key "${KAS_KEY_SYSTEM_ID}" --json + assert_success + # Verify the new base key part of the response + assert_equal "$(echo "$output" | jq -r .new_base_key.public_key.kid)" "${REGULAR_KEY_ID_FOR_BASE_TEST}" + assert_equal "$(echo "$output" | jq -r .new_base_key.kas_uri)" "${KAS_URI_BASE_KEY_TEST}" + assert_not_equal "$(echo "$output" | jq -r .new_base_key.public_key.pem)" "" + assert_not_equal "$(echo "$output" | jq -r .new_base_key.public_key.pem)" "null" + assert_equal "$(echo "$output" | jq -r .new_base_key.public_key.algorithm)" 1 + # Verify previous base key is null or not present if this is the first set + assert_equal "$(echo "$output" | jq -r .previous_base_key)" "null" +} + +@test "base-key: set by --key(id) and --kas(id)" { + run_otdfctl_base_key set --key "${REGULAR_KEY_ID_FOR_BASE_TEST}" --kas "${KAS_REGISTRY_ID_BASE_KEY_TEST}" --json + assert_success + # Verify the new base key part of the response + assert_equal "$(echo "$output" | jq -r .new_base_key.public_key.kid)" "${REGULAR_KEY_ID_FOR_BASE_TEST}" + assert_equal "$(echo "$output" | jq -r .new_base_key.kas_uri)" "${KAS_URI_BASE_KEY_TEST}" + assert_not_equal "$(echo "$output" | jq -r .new_base_key.public_key.pem)" "" + assert_not_equal "$(echo "$output" | jq -r .new_base_key.public_key.pem)" "null" + assert_equal "$(echo "$output" | jq -r .new_base_key.public_key.algorithm)" 1 +} + +@test "base-key: get (after setting a base key)" { + run_otdfctl_base_key set --key "${KAS_KEY_SYSTEM_ID}" --json + assert_success + + run_otdfctl_base_key get --json + assert_success + assert_equal "$(echo "$output" | jq -r .public_key.kid)" "${REGULAR_KEY_ID_FOR_BASE_TEST}" + assert_equal "$(echo "$output" | jq -r .kas_uri)" "${KAS_URI_BASE_KEY_TEST}" + assert_not_equal "$(echo "$output" | jq -r .public_key.pem)" "" + assert_not_equal "$(echo "$output" | jq -r .public_key.pem)" "null" + assert_equal "$(echo "$output" | jq -r .public_key.algorithm)" 1 +} + +@test "base-key: set by --key(id) and --kas(name)" { + run_otdfctl_base_key set --key "${REGULAR_KEY_ID_FOR_BASE_TEST}" --kas "${KAS_NAME_BASE_KEY_TEST}" --json + assert_success + assert_equal "$(echo "$output" | jq -r .new_base_key.public_key.kid)" "${REGULAR_KEY_ID_FOR_BASE_TEST}" + assert_equal "$(echo "$output" | jq -r .new_base_key.kas_uri)" "${KAS_URI_BASE_KEY_TEST}" # KAS URI should remain the same for the KAS Name + assert_not_equal "$(echo "$output" | jq -r .new_base_key.public_key.pem)" "" + assert_not_equal "$(echo "$output" | jq -r .new_base_key.public_key.pem)" "null" + assert_equal "$(echo "$output" | jq -r .new_base_key.public_key.algorithm)" 1 +} + +@test "base-key: set by --key(id) and --kas(uri)" { + # This will set REGULAR_KEY_ID_FOR_BASE_TEST back as the base key + run_otdfctl_base_key set --key "${REGULAR_KEY_ID_FOR_BASE_TEST}" --kas "${KAS_URI_BASE_KEY_TEST}" --json + assert_success + # Verify the new base key + assert_equal "$(echo "$output" | jq -r .new_base_key.public_key.kid)" "${REGULAR_KEY_ID_FOR_BASE_TEST}" + assert_equal "$(echo "$output" | jq -r .new_base_key.kas_uri)" "${KAS_URI_BASE_KEY_TEST}" # KAS URI should remain the same for the KAS Name + assert_not_equal "$(echo "$output" | jq -r .new_base_key.public_key.pem)" "" + assert_not_equal "$(echo "$output" | jq -r .new_base_key.public_key.pem)" "null" + assert_equal "$(echo "$output" | jq -r .new_base_key.public_key.algorithm)" 1 +} + +@test "base-key: set, get, and verify previous base key" { + run_otdfctl_base_key set --key "${KAS_KEY_SYSTEM_ID}" --json + assert_success + assert_equal "$(echo "$output" | jq -r .new_base_key.public_key.kid)" "${REGULAR_KEY_ID_FOR_BASE_TEST}" + assert_equal "$(echo "$output" | jq -r .new_base_key.kas_uri)" "${KAS_URI_BASE_KEY_TEST}" + assert_not_equal "$(echo "$output" | jq -r .new_base_key.public_key.pem)" "" + assert_not_equal "$(echo "$output" | jq -r .new_base_key.public_key.pem)" "null" + assert_equal "$(echo "$output" | jq -r .new_base_key.public_key.algorithm)" 1 + + run_otdfctl_base_key get --json + assert_success + assert_equal "$(echo "$output" | jq -r .public_key.kid)" "${REGULAR_KEY_ID_FOR_BASE_TEST}" + assert_equal "$(echo "$output" | jq -r .kas_uri)" "${KAS_URI_BASE_KEY_TEST}" + assert_not_equal "$(echo "$output" | jq -r .public_key.pem)" "" + assert_not_equal "$(echo "$output" | jq -r .public_key.pem)" "null" + assert_equal "$(echo "$output" | jq -r .public_key.algorithm)" 1 + + SECOND_KEY_ID_FOR_BASE_TEST="second-key-for-base-$(date +%s)" + SECOND_KAS_KEY_SYSTEM_ID=$(./otdfctl $HOST $WITH_CREDS policy kas-registry key create --kas "${KAS_REGISTRY_ID_BASE_KEY_TEST}" --key-id "${SECOND_KEY_ID_FOR_BASE_TEST}" --algorithm ec:secp256r1 --mode local --wrapping-key "${WRAPPING_KEY}" --wrapping-key-id "test-key" --json | jq -r '.key.id') + + run_otdfctl_base_key set --key "${SECOND_KAS_KEY_SYSTEM_ID}" --json + assert_success + assert_equal "$(echo "$output" | jq -r .new_base_key.public_key.kid)" "${SECOND_KEY_ID_FOR_BASE_TEST}" + assert_equal "$(echo "$output" | jq -r .new_base_key.kas_uri)" "${KAS_URI_BASE_KEY_TEST}" + assert_not_equal "$(echo "$output" | jq -r .new_base_key.public_key.pem)" "" + assert_not_equal "$(echo "$output" | jq -r .new_base_key.public_key.pem)" "null" + assert_equal "$(echo "$output" | jq -r .new_base_key.public_key.algorithm)" 3 + # Verify previous base key + assert_equal "$(echo "$output" | jq -r .previous_base_key.public_key.kid)" "${REGULAR_KEY_ID_FOR_BASE_TEST}" + assert_equal "$(echo "$output" | jq -r .previous_base_key.kas_uri)" "${KAS_URI_BASE_KEY_TEST}" + assert_not_equal "$(echo "$output" | jq -r .new_base_key.public_key.pem)" "" + assert_not_equal "$(echo "$output" | jq -r .new_base_key.public_key.pem)" "null" + assert_equal "$(echo "$output" | jq -r .previous_base_key.public_key.algorithm)" 1 +} + +@test "base-key: set (missing kas identifier)" { + run_otdfctl_base_key set --key "${REGULAR_KEY_ID_FOR_BASE_TEST}" + assert_failure + assert_output --partial "Flag '--kas' is required" +} + +@test "base-key: set (missing key identifier: id or keyId)" { + run_otdfctl_base_key set --kas "${KAS_REGISTRY_ID_BASE_KEY_TEST}" + assert_failure + assert_output --partial "Flag '--key' is required" +} + +@test "base-key: set (using non-existent keyId)" { + NON_EXISTENT_KEY_ID="this-key-does-not-exist-12345" + run_otdfctl_base_key set --key "${NON_EXISTENT_KEY_ID}" --kas "${KAS_REGISTRY_ID_BASE_KEY_TEST}" + assert_failure + # The exact error message might depend on the backend implementation + assert_output --partial "not_found" # Or a more specific "key not found" error +} + +@test "base-key: set (using non-existent kasId)" { + NON_EXISTENT_KAS_ID="a1b2c3d4-e5f6-7890-1234-567890abcdef" + run_otdfctl_base_key set --key "${REGULAR_KEY_ID_FOR_BASE_TEST}" --kas "${NON_EXISTENT_KAS_ID}" + assert_failure + assert_output --partial "not_found" # Or a more specific "KAS not found" error +} diff --git a/otdfctl/e2e/logging.bats b/otdfctl/e2e/logging.bats new file mode 100644 index 0000000000..43b308ec67 --- /dev/null +++ b/otdfctl/e2e/logging.bats @@ -0,0 +1,24 @@ +#!/usr/bin/env bats + +setup() { + load "${BATS_LIB_PATH}/bats-support/load.bash" + load "${BATS_LIB_PATH}/bats-assert/load.bash" +} + +@test "version is logged to stderr when debug logging enabled" { + run --separate-stderr -- ./otdfctl --version --log-level debug + + assert_success + assert_output --partial "otdfctl version" + [[ "$stderr" == *"otdfctl version"* ]] + [[ "$stderr" == *"\"level\":\"DEBUG\""* ]] +} + +@test "version is logged to stderr when debug enabled" { + run --separate-stderr -- ./otdfctl --version --debug + + assert_success + assert_output --partial "otdfctl version" + [[ "$stderr" == *"otdfctl version"* ]] + [[ "$stderr" == *"\"level\":\"DEBUG\""* ]] +} diff --git a/otdfctl/e2e/migrate-namespaced-policy.bats b/otdfctl/e2e/migrate-namespaced-policy.bats new file mode 100644 index 0000000000..f8a71631e6 --- /dev/null +++ b/otdfctl/e2e/migrate-namespaced-policy.bats @@ -0,0 +1,940 @@ +#!/usr/bin/env bats + +# bats file_tags=namespaced_policy_migration + +# Tests for namespaced-policy migration +# This file needs isolated execution while the rest of otdfctl/e2e is still +# running in parallel. The migration planner discovers legacy/global policy +# objects by scope, so overlapping unnamespaced fixtures from other BATS files +# can pollute these migration assertions. +# CI should run this tag in a separate invocation, then run the remaining suite +# with this tag filtered out. + +load "${BATS_LIB_PATH}/bats-support/load.bash" +load "${BATS_LIB_PATH}/bats-assert/load.bash" + +run_otdfctl_migrate() { + run ./otdfctl --host http://localhost:8080 --with-client-creds-file ./creds.json migrate "$@" +} + +run_otdfctl_action() { + run ./otdfctl --host http://localhost:8080 --with-client-creds-file ./creds.json policy actions "$@" +} + +run_otdfctl_sm() { + run ./otdfctl --host http://localhost:8080 --with-client-creds-file ./creds.json policy subject-mappings "$@" +} + +run_otdfctl_scs() { + run ./otdfctl --host http://localhost:8080 --with-client-creds-file ./creds.json policy scs "$@" +} + +track_action_id() { + local action_id="$1" + TRACKED_ACTION_IDS="${TRACKED_ACTION_IDS}${action_id}"$'\n' +} + +track_scs_id() { + local scs_id="$1" + TRACKED_SCS_IDS="${TRACKED_SCS_IDS}${scs_id}"$'\n' +} + +track_subject_mapping_id() { + local subject_mapping_id="$1" + TRACKED_SUBJECT_MAPPING_IDS="${TRACKED_SUBJECT_MAPPING_IDS}${subject_mapping_id}"$'\n' +} + +create_global_action() { + local result_var="$1" + local action_name="$2" + shift 2 + + run ./otdfctl --host http://localhost:8080 --with-client-creds-file ./creds.json policy actions create --name "$action_name" "$@" --json + assert_success + + local action_id + action_id=$(echo "$output" | jq -r '.id // empty') + assert_not_equal "$action_id" "" + + track_action_id "$action_id" + printf -v "$result_var" '%s' "$action_id" +} + +create_global_scs() { + local result_var="$1" + local subject_sets_json="$2" + shift 2 + + run ./otdfctl --host http://localhost:8080 --with-client-creds-file ./creds.json policy scs create --subject-sets "$subject_sets_json" "$@" --json + assert_success + + local scs_id + scs_id=$(echo "$output" | jq -r '.id // empty') + assert_not_equal "$scs_id" "" + + track_scs_id "$scs_id" + printf -v "$result_var" '%s' "$scs_id" +} + +create_namespaced_scs() { + local result_var="$1" + local namespace_id="$2" + local subject_sets_json="$3" + shift 3 + + run ./otdfctl --host http://localhost:8080 --with-client-creds-file ./creds.json policy scs create --namespace "$namespace_id" --subject-sets "$subject_sets_json" "$@" --json + assert_success + + local scs_id + scs_id=$(echo "$output" | jq -r '.id // empty') + assert_not_equal "$scs_id" "" + + track_scs_id "$scs_id" + printf -v "$result_var" '%s' "$scs_id" +} + +create_legacy_subject_mapping() { + local result_var="$1" + local attribute_value_id="$2" + local action_id="$3" + local subject_condition_set_id="$4" + shift 4 + + run ./otdfctl --host http://localhost:8080 --with-client-creds-file ./creds.json policy subject-mappings create --attribute-value-id "$attribute_value_id" --action "$action_id" --subject-condition-set-id "$subject_condition_set_id" "$@" --json + assert_success + + local subject_mapping_id + subject_mapping_id=$(echo "$output" | jq -r '.id // empty') + assert_not_equal "$subject_mapping_id" "" + + track_subject_mapping_id "$subject_mapping_id" + printf -v "$result_var" '%s' "$subject_mapping_id" +} + +subject_mapping_plan_target_count() { + local output_file="$1" + local source_mapping_id="$2" + jq -er --arg source_mapping_id "$source_mapping_id" ' + [ + .subject_mappings[] + | select(.source.id == $source_mapping_id) + | .targets[] + ] | length + ' "$output_file" +} + +subject_mapping_plan_target_status() { + local output_file="$1" + local source_mapping_id="$2" + local namespace_fqn="$3" + jq -er --arg source_mapping_id "$source_mapping_id" --arg namespace_fqn "$namespace_fqn" ' + .subject_mappings[] + | select(.source.id == $source_mapping_id) + | .targets[] + | select(.namespace.fqn == $namespace_fqn) + | .status + ' "$output_file" +} + +subject_mapping_plan_target_effective_id() { + local output_file="$1" + local source_mapping_id="$2" + local namespace_fqn="$3" + jq -er --arg source_mapping_id "$source_mapping_id" --arg namespace_fqn "$namespace_fqn" ' + .subject_mappings[] + | select(.source.id == $source_mapping_id) + | .targets[] + | select(.namespace.fqn == $namespace_fqn) + | (.execution.created_target_id // .existing.id // empty) + ' "$output_file" +} + +subject_mapping_plan_action_status() { + local output_file="$1" + local source_mapping_id="$2" + local namespace_fqn="$3" + local source_action_id="$4" + jq -er --arg source_mapping_id "$source_mapping_id" --arg namespace_fqn "$namespace_fqn" --arg source_action_id "$source_action_id" ' + .subject_mappings[] + | select(.source.id == $source_mapping_id) + | .targets[] + | select(.namespace.fqn == $namespace_fqn) + | .actions[] + | select(.source_id == $source_action_id) + | .status + ' "$output_file" +} + +subject_mapping_plan_scs_status() { + local output_file="$1" + local source_mapping_id="$2" + local namespace_fqn="$3" + jq -er --arg source_mapping_id "$source_mapping_id" --arg namespace_fqn "$namespace_fqn" ' + .subject_mappings[] + | select(.source.id == $source_mapping_id) + | .targets[] + | select(.namespace.fqn == $namespace_fqn) + | .subject_condition_set.status + ' "$output_file" +} + +assert_subject_mapping_target_count() { + local output_file="$1" + local source_mapping_id="$2" + local expected_count="$3" + + run subject_mapping_plan_target_count "$output_file" "$source_mapping_id" + assert_success + assert_equal "$output" "$expected_count" +} + +assert_subject_mapping_created_in_namespace() { + local output_file="$1" + local source_mapping_id="$2" + local namespace_id="$3" + local namespace_fqn="$4" + local attribute_value_id="$5" + local action_name="$6" + local source_action_id="$7" + local expected_action_status="$8" + local expected_action_count="$9" + local source_scs_id="${10}" + local expected_scs_count="${11}" + + run subject_mapping_plan_target_status "$output_file" "$source_mapping_id" "$namespace_fqn" + assert_success + assert_equal "$output" "create" + + assert_action_target_count "$output_file" "$action_name" "$expected_action_count" + case "$expected_action_status" in + create) + assert_custom_action_created_in_namespace "$output_file" "$action_name" "$source_action_id" "$namespace_id" "$namespace_fqn" + ;; + existing_standard) + assert_standard_action_resolved_in_namespace "$output_file" "$action_name" "$namespace_id" "$namespace_fqn" + ;; + *) + false + ;; + esac + + run subject_mapping_plan_action_status "$output_file" "$source_mapping_id" "$namespace_fqn" "$source_action_id" + assert_success + assert_equal "$output" "$expected_action_status" + + local expected_action_target_id + expected_action_target_id=$(action_plan_target_effective_id "$output_file" "$action_name" "$namespace_fqn") + assert_not_equal "$expected_action_target_id" "" + + assert_scs_target_count "$output_file" "$source_scs_id" "$expected_scs_count" + assert_scs_created_in_namespace "$output_file" "$source_scs_id" "$namespace_id" "$namespace_fqn" + + run subject_mapping_plan_scs_status "$output_file" "$source_mapping_id" "$namespace_fqn" + assert_success + assert_equal "$output" "create" + + local expected_scs_target_id + expected_scs_target_id=$(scs_plan_target_effective_id "$output_file" "$source_scs_id" "$namespace_fqn") + assert_not_equal "$expected_scs_target_id" "" + + local created_target_id + created_target_id=$(subject_mapping_plan_target_effective_id "$output_file" "$source_mapping_id" "$namespace_fqn") + assert_not_equal "$created_target_id" "" + assert_not_equal "$created_target_id" "$source_mapping_id" + + local source_mapping_json + source_mapping_json=$(./otdfctl $HOST $WITH_CREDS policy subject-mappings get --id "$source_mapping_id" --json) + + local created_mapping_json + created_mapping_json=$(./otdfctl $HOST $WITH_CREDS policy subject-mappings get --id "$created_target_id" --json) + + assert_equal "$(echo "$created_mapping_json" | jq -r '.id')" "$created_target_id" + assert_equal "$(echo "$created_mapping_json" | jq -r '.namespace.id')" "$namespace_id" + assert_equal "$(echo "$created_mapping_json" | jq -r '.attribute_value.id')" "$attribute_value_id" + assert_equal "$(echo "$created_mapping_json" | jq -r '.actions[0].id')" "$expected_action_target_id" + assert_equal "$(echo "$created_mapping_json" | jq -r '.subject_condition_set.id')" "$expected_scs_target_id" + assert_metadata_labels_preserved "$source_mapping_json" "$created_mapping_json" + assert_equal "$(echo "$created_mapping_json" | jq -r '.metadata.labels.migrated_from')" "$source_mapping_id" + assert_not_equal "$(echo "$created_mapping_json" | jq -r '.metadata.labels.migration_run // empty')" "" +} + +assert_subject_mapping_already_migrated_in_namespace() { + local output_file="$1" + local source_mapping_id="$2" + local namespace_id="$3" + local namespace_fqn="$4" + local existing_mapping_id="$5" + + assert_not_equal "$existing_mapping_id" "" + + run subject_mapping_plan_target_status "$output_file" "$source_mapping_id" "$namespace_fqn" + assert_success + assert_equal "$output" "already_migrated" + + local effective_target_id + effective_target_id=$(subject_mapping_plan_target_effective_id "$output_file" "$source_mapping_id" "$namespace_fqn") + assert_not_equal "$effective_target_id" "" + assert_equal "$effective_target_id" "$existing_mapping_id" + + local existing_mapping_json + existing_mapping_json=$(./otdfctl $HOST $WITH_CREDS policy subject-mappings get --id "$existing_mapping_id" --json) + + assert_equal "$(echo "$existing_mapping_json" | jq -r '.id // empty')" "$existing_mapping_id" + assert_equal "$(echo "$existing_mapping_json" | jq -r '.namespace.id')" "$namespace_id" +} + +assert_legacy_subject_mapping_still_exists() { + local attribute_value_id="$1" + local source_mapping_id="$2" + + assert_not_equal "$attribute_value_id" "" + assert_not_equal "$source_mapping_id" "" + + local legacy_mapping_json + legacy_mapping_json=$(./otdfctl $HOST $WITH_CREDS policy subject-mappings get --id "$source_mapping_id" --json) + + assert_equal "$(echo "$legacy_mapping_json" | jq -r '.id // empty')" "$source_mapping_id" + assert_equal "$(echo "$legacy_mapping_json" | jq -r '.namespace.id // empty')" "" + assert_equal "$(echo "$legacy_mapping_json" | jq -r '.attribute_value.id')" "$attribute_value_id" +} + +assert_no_subject_mappings_in_namespace() { + local namespace_id="$1" + + run sh -c "./otdfctl $HOST $WITH_CREDS policy subject-mappings list --namespace \"$namespace_id\" --json" + assert_success + assert_equal "$(echo "$output" | jq -r '.subject_mappings | length')" "0" +} + +assert_metadata_labels_preserved() { + local source_json="$1" + local target_json="$2" + + local source_labels + source_labels=$(echo "$source_json" | jq -c '.metadata.labels // {}') + assert_not_equal "$source_labels" "{}" + + local target_labels + target_labels=$(echo "$target_json" | jq -c '(.metadata.labels // {}) | del(.migrated_from, .migration_run)') + + assert_equal "$target_labels" "$source_labels" +} + +action_plan_source_id() { + local output_file="$1" + local action_name="$2" + jq -er --arg action_name "$action_name" ' + .actions[] + | select(.source.name == $action_name) + | .source.id + ' "$output_file" +} + +action_plan_target_count() { + local output_file="$1" + local action_name="$2" + jq -er --arg action_name "$action_name" ' + [ + .actions[] + | select(.source.name == $action_name) + | .targets[] + ] | length + ' "$output_file" +} + +action_plan_target_status() { + local output_file="$1" + local action_name="$2" + local namespace_fqn="$3" + jq -er --arg action_name "$action_name" --arg namespace_fqn "$namespace_fqn" ' + .actions[] + | select(.source.name == $action_name) + | .targets[] + | select(.namespace.fqn == $namespace_fqn) + | .status + ' "$output_file" +} + +action_plan_target_effective_id() { + local output_file="$1" + local action_name="$2" + local namespace_fqn="$3" + jq -er --arg action_name "$action_name" --arg namespace_fqn "$namespace_fqn" ' + .actions[] + | select(.source.name == $action_name) + | .targets[] + | select(.namespace.fqn == $namespace_fqn) + | (.execution.created_target_id // .existing.id // empty) + ' "$output_file" +} + +scs_plan_target_count() { + local output_file="$1" + local source_scs_id="$2" + jq -er --arg source_scs_id "$source_scs_id" ' + [ + .subject_condition_sets[] + | select(.source.id == $source_scs_id) + | .targets[] + ] | length + ' "$output_file" +} + +scs_plan_target_status() { + local output_file="$1" + local source_scs_id="$2" + local namespace_fqn="$3" + jq -er --arg source_scs_id "$source_scs_id" --arg namespace_fqn "$namespace_fqn" ' + .subject_condition_sets[] + | select(.source.id == $source_scs_id) + | .targets[] + | select(.namespace.fqn == $namespace_fqn) + | .status + ' "$output_file" +} + +scs_plan_target_effective_id() { + local output_file="$1" + local source_scs_id="$2" + local namespace_fqn="$3" + jq -er --arg source_scs_id "$source_scs_id" --arg namespace_fqn "$namespace_fqn" ' + .subject_condition_sets[] + | select(.source.id == $source_scs_id) + | .targets[] + | select(.namespace.fqn == $namespace_fqn) + | (.execution.created_target_id // .existing.id // empty) + ' "$output_file" +} + +assert_action_target_count() { + local output_file="$1" + local action_name="$2" + local expected_count="$3" + + run action_plan_target_count "$output_file" "$action_name" + assert_success + assert_equal "$output" "$expected_count" +} + +assert_scs_target_count() { + local output_file="$1" + local source_scs_id="$2" + local expected_count="$3" + + run scs_plan_target_count "$output_file" "$source_scs_id" + assert_success + assert_equal "$output" "$expected_count" +} + +assert_action_target_absent() { + local output_file="$1" + local action_name="$2" + local namespace_fqn="$3" + + run jq -e --arg action_name "$action_name" --arg namespace_fqn "$namespace_fqn" ' + .actions[] + | select(.source.name == $action_name) + | .targets[] + | select(.namespace.fqn == $namespace_fqn) + ' "$output_file" + assert_failure +} + +assert_scs_target_absent() { + local output_file="$1" + local source_scs_id="$2" + local namespace_fqn="$3" + + run jq -e --arg source_scs_id "$source_scs_id" --arg namespace_fqn "$namespace_fqn" ' + .subject_condition_sets[] + | select(.source.id == $source_scs_id) + | .targets[] + | select(.namespace.fqn == $namespace_fqn) + ' "$output_file" + assert_failure +} + +assert_standard_action_resolved_in_namespace() { + local output_file="$1" + local action_name="$2" + local namespace_id="$3" + local namespace_fqn="$4" + + run action_plan_target_status "$output_file" "$action_name" "$namespace_fqn" + assert_success + assert_equal "$output" "existing_standard" + + local planned_target_id + planned_target_id=$(action_plan_target_effective_id "$output_file" "$action_name" "$namespace_fqn") + assert_not_equal "$planned_target_id" "" + + local live_target_id + live_target_id=$(./otdfctl $HOST $WITH_CREDS policy actions get --name "$action_name" --namespace "$namespace_id" --json | jq -r '.id // empty') + assert_not_equal "$live_target_id" "" + + assert_equal "$planned_target_id" "$live_target_id" +} + +assert_action_already_migrated_in_namespace() { + local output_file="$1" + local action_name="$2" + local namespace_id="$3" + local namespace_fqn="$4" + local existing_action_id="$5" + + assert_not_equal "$existing_action_id" "" + + run action_plan_target_status "$output_file" "$action_name" "$namespace_fqn" + assert_success + assert_equal "$output" "already_migrated" + + local effective_target_id + effective_target_id=$(action_plan_target_effective_id "$output_file" "$action_name" "$namespace_fqn") + assert_not_equal "$effective_target_id" "" + assert_equal "$effective_target_id" "$existing_action_id" + + local existing_action_json + existing_action_json=$(./otdfctl $HOST $WITH_CREDS policy actions get --id "$existing_action_id" --json) + + assert_equal "$(echo "$existing_action_json" | jq -r '.id // empty')" "$existing_action_id" + assert_equal "$(echo "$existing_action_json" | jq -r '.namespace.id')" "$namespace_id" +} + +assert_custom_action_created_in_namespace() { + local output_file="$1" + local action_name="$2" + local source_action_id="$3" + local namespace_id="$4" + local namespace_fqn="$5" + + run action_plan_target_status "$output_file" "$action_name" "$namespace_fqn" + assert_success + assert_equal "$output" "create" + + local created_target_id + created_target_id=$(action_plan_target_effective_id "$output_file" "$action_name" "$namespace_fqn") + assert_not_equal "$created_target_id" "" + assert_not_equal "$created_target_id" "$source_action_id" + + local source_action_json + source_action_json=$(./otdfctl $HOST $WITH_CREDS policy actions get --id "$source_action_id" --json) + + local created_action_json + created_action_json=$(./otdfctl $HOST $WITH_CREDS policy actions get --id "$created_target_id" --json) + + assert_equal "$(echo "$created_action_json" | jq -r '.id')" "$created_target_id" + assert_equal "$(echo "$created_action_json" | jq -r '.name')" "$action_name" + assert_equal "$(echo "$created_action_json" | jq -r '.namespace.id')" "$namespace_id" + assert_metadata_labels_preserved "$source_action_json" "$created_action_json" + assert_equal "$(echo "$created_action_json" | jq -r '.metadata.labels.migrated_from')" "$source_action_id" + assert_not_equal "$(echo "$created_action_json" | jq -r '.metadata.labels.migration_run // empty')" "" +} + +assert_legacy_custom_action_still_exists() { + local action_id="$1" + local action_name="$2" + + assert_not_equal "$action_id" "" + assert_not_equal "$action_name" "" + + local legacy_action_json + legacy_action_json=$(./otdfctl $HOST $WITH_CREDS policy actions get --id "$action_id" --json) + + assert_equal "$(echo "$legacy_action_json" | jq -r '.id // empty')" "$action_id" + assert_equal "$(echo "$legacy_action_json" | jq -r '.name')" "$action_name" + assert_equal "$(echo "$legacy_action_json" | jq -r '.namespace.id // empty')" "" +} + +assert_scs_created_in_namespace() { + local output_file="$1" + local source_scs_id="$2" + local namespace_id="$3" + local namespace_fqn="$4" + + run scs_plan_target_status "$output_file" "$source_scs_id" "$namespace_fqn" + assert_success + assert_equal "$output" "create" + + local created_target_id + created_target_id=$(scs_plan_target_effective_id "$output_file" "$source_scs_id" "$namespace_fqn") + assert_not_equal "$created_target_id" "" + assert_not_equal "$created_target_id" "$source_scs_id" + + local source_scs_json + source_scs_json=$(./otdfctl $HOST $WITH_CREDS policy scs get --id "$source_scs_id" --json) + + local created_scs_json + created_scs_json=$(./otdfctl $HOST $WITH_CREDS policy scs get --id "$created_target_id" --json) + + assert_equal "$(echo "$created_scs_json" | jq -r '.id')" "$created_target_id" + assert_equal "$(echo "$created_scs_json" | jq -r '.namespace.id')" "$namespace_id" + assert_equal "$(echo "$created_scs_json" | jq -c '.subject_sets')" "$(echo "$source_scs_json" | jq -c '.subject_sets')" + assert_metadata_labels_preserved "$source_scs_json" "$created_scs_json" + assert_equal "$(echo "$created_scs_json" | jq -r '.metadata.labels.migrated_from')" "$source_scs_id" + assert_not_equal "$(echo "$created_scs_json" | jq -r '.metadata.labels.migration_run // empty')" "" +} + +assert_scs_already_migrated_in_namespace() { + local output_file="$1" + local source_scs_id="$2" + local namespace_id="$3" + local namespace_fqn="$4" + local existing_scs_id="$5" + + assert_not_equal "$existing_scs_id" "" + + run scs_plan_target_status "$output_file" "$source_scs_id" "$namespace_fqn" + assert_success + assert_equal "$output" "already_migrated" + + local effective_target_id + effective_target_id=$(scs_plan_target_effective_id "$output_file" "$source_scs_id" "$namespace_fqn") + assert_not_equal "$effective_target_id" "" + assert_equal "$effective_target_id" "$existing_scs_id" + + local source_scs_json + source_scs_json=$(./otdfctl $HOST $WITH_CREDS policy scs get --id "$source_scs_id" --json) + + local existing_scs_json + existing_scs_json=$(./otdfctl $HOST $WITH_CREDS policy scs get --id "$existing_scs_id" --json) + + assert_equal "$(echo "$existing_scs_json" | jq -r '.id // empty')" "$existing_scs_id" + assert_equal "$(echo "$existing_scs_json" | jq -r '.namespace.id')" "$namespace_id" + assert_equal "$(echo "$existing_scs_json" | jq -c '.subject_sets')" "$(echo "$source_scs_json" | jq -c '.subject_sets')" +} + +assert_legacy_scs_still_exists() { + local source_scs_id="$1" + + assert_not_equal "$source_scs_id" "" + + local legacy_scs_json + legacy_scs_json=$(./otdfctl $HOST $WITH_CREDS policy scs get --id "$source_scs_id" --json) + + assert_equal "$(echo "$legacy_scs_json" | jq -r '.id // empty')" "$source_scs_id" + assert_equal "$(echo "$legacy_scs_json" | jq -r '.namespace.id // empty')" "" +} + +run_namespaced_policy_commit() { + local scope="$1" + local output_file="$2" + + run_otdfctl_migrate --commit namespaced-policy --scope "$scope" --output "$output_file" +} + +setup() { + export TEST_PREFIX="${MIGRATION_TEST_PREFIX}-t${BATS_TEST_NUMBER}" + export TRACKED_ACTION_IDS="" + export TRACKED_SCS_IDS="" + export TRACKED_SUBJECT_MAPPING_IDS="" +} + +setup_file() { + export WITH_CREDS='--with-client-creds-file ./creds.json' + export HOST='--host http://localhost:8080' + + export MIGRATION_TEST_PREFIX="np-migrate-$(date +%s)" + export MIGRATION_OUTPUT_DIR="/tmp/${MIGRATION_TEST_PREFIX}" + mkdir -p "$MIGRATION_OUTPUT_DIR" + + export NS_A_NAME="${MIGRATION_TEST_PREFIX}-a.test" + export NS_B_NAME="${MIGRATION_TEST_PREFIX}-b.test" + export NS_A_FQN="https://${NS_A_NAME}" + export NS_B_FQN="https://${NS_B_NAME}" + + run sh -c "./otdfctl $HOST $WITH_CREDS policy attributes namespaces create --name \"$NS_A_NAME\" --json" + assert_success + export NS_A_ID + NS_A_ID=$(echo "$output" | jq -r '.id // empty') + assert_not_equal "$NS_A_ID" "" + + run sh -c "./otdfctl $HOST $WITH_CREDS policy attributes namespaces create --name \"$NS_B_NAME\" --json" + assert_success + export NS_B_ID + NS_B_ID=$(echo "$output" | jq -r '.id // empty') + assert_not_equal "$NS_B_ID" "" + + run sh -c "./otdfctl $HOST $WITH_CREDS policy attributes create --name \"${MIGRATION_TEST_PREFIX}-attr-a\" --namespace \"$NS_A_ID\" --rule ANY_OF -v \"${MIGRATION_TEST_PREFIX}-a1\" --json" + assert_success + attr_a_json="$output" + export ATTR_A_ID ATTR_A_VAL_1_ID + ATTR_A_ID=$(echo "$attr_a_json" | jq -r '.id // empty') + ATTR_A_VAL_1_ID=$(echo "$attr_a_json" | jq -r '.values[0].id // empty') + assert_not_equal "$ATTR_A_ID" "" + assert_not_equal "$ATTR_A_VAL_1_ID" "" + + # ATTR_A values resolve under the namespace FQN: + # ${NS_A_FQN}/attr/${MIGRATION_TEST_PREFIX}-attr-a/value/${MIGRATION_TEST_PREFIX}-a1 + # ${NS_A_FQN}/attr/${MIGRATION_TEST_PREFIX}-attr-a/value/${MIGRATION_TEST_PREFIX}-a2 + run sh -c "./otdfctl $HOST $WITH_CREDS policy attributes values create --attribute-id \"$ATTR_A_ID\" --value \"${MIGRATION_TEST_PREFIX}-a2\" --json" + assert_success + export ATTR_A_VAL_2_ID + ATTR_A_VAL_2_ID=$(echo "$output" | jq -r '.id // empty') + assert_not_equal "$ATTR_A_VAL_2_ID" "" + + # ATTR_B values resolve under the namespace FQN: + # ${NS_B_FQN}/attr/${MIGRATION_TEST_PREFIX}-attr-b/value/${MIGRATION_TEST_PREFIX}-b1 + run sh -c "./otdfctl $HOST $WITH_CREDS policy attributes create --name \"${MIGRATION_TEST_PREFIX}-attr-b\" --namespace \"$NS_B_ID\" --rule ANY_OF -v \"${MIGRATION_TEST_PREFIX}-b1\" --json" + assert_success + attr_b_json="$output" + export ATTR_B_ID ATTR_B_VAL_1_ID + ATTR_B_ID=$(echo "$attr_b_json" | jq -r '.id // empty') + ATTR_B_VAL_1_ID=$(echo "$attr_b_json" | jq -r '.values[0].id // empty') + assert_not_equal "$ATTR_B_ID" "" + assert_not_equal "$ATTR_B_VAL_1_ID" "" + + run sh -c "./otdfctl $HOST $WITH_CREDS policy actions get --name read --json" + assert_success + export GLOBAL_READ_ID + GLOBAL_READ_ID=$(echo "$output" | jq -r '.id // empty') + assert_not_equal "$GLOBAL_READ_ID" "" +} + +teardown() { + local subject_mapping_id + local delete_output + local delete_status + while IFS= read -r subject_mapping_id; do + [ -n "$subject_mapping_id" ] || continue + if delete_output=$(./otdfctl $HOST $WITH_CREDS policy subject-mappings delete --id "$subject_mapping_id" --force 2>&1); then + : + else + delete_status=$? + echo "warning: failed to delete subject mapping fixture $subject_mapping_id during teardown (exit $delete_status): $delete_output" >&2 + fi + done <<< "$TRACKED_SUBJECT_MAPPING_IDS" + + local scs_id + while IFS= read -r scs_id; do + [ -n "$scs_id" ] || continue + if delete_output=$(./otdfctl $HOST $WITH_CREDS policy scs delete --id "$scs_id" --force 2>&1); then + : + else + delete_status=$? + echo "warning: failed to delete subject condition set fixture $scs_id during teardown (exit $delete_status): $delete_output" >&2 + fi + done <<< "$TRACKED_SCS_IDS" + + local action_id + while IFS= read -r action_id; do + [ -n "$action_id" ] || continue + if delete_output=$(./otdfctl $HOST $WITH_CREDS policy actions delete --id "$action_id" --force 2>&1); then + : + else + delete_status=$? + echo "warning: failed to delete action fixture $action_id during teardown (exit $delete_status): $delete_output" >&2 + fi + done <<< "$TRACKED_ACTION_IDS" +} + +teardown_file() { + ./otdfctl $HOST $WITH_CREDS policy attributes namespaces unsafe delete --id "$NS_A_ID" --force + ./otdfctl $HOST $WITH_CREDS policy attributes namespaces unsafe delete --id "$NS_B_ID" --force + + rm -rf "$MIGRATION_OUTPUT_DIR" + + unset HOST WITH_CREDS MIGRATION_TEST_PREFIX MIGRATION_OUTPUT_DIR TEST_PREFIX + unset NS_A_NAME NS_B_NAME NS_A_FQN NS_B_FQN NS_A_ID NS_B_ID + unset ATTR_A_ID ATTR_A_VAL_1_ID ATTR_A_VAL_2_ID ATTR_B_ID ATTR_B_VAL_1_ID + unset GLOBAL_READ_ID + unset TRACKED_ACTION_IDS TRACKED_SCS_IDS TRACKED_SUBJECT_MAPPING_IDS +} + +# Asserts action-scope migration resolves shared standard actions in-place, +# creates only the required custom action target, preserves metadata, does not +# create namespaced subject mappings as a side effect, and is idempotent on +# rerun. +@test "migrate namespaced-policy actions resolves standard actions and creates custom actions" { + local custom_action_name="${TEST_PREFIX}-download" + local shared_scs='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["'"${TEST_PREFIX}"'-engineering"],"subject_external_selector_value":".org.name"}],"boolean_operator":1}]}]' + local custom_action_labels=(--label "test_case=actions" --label "fixture=${TEST_PREFIX}-custom-action") + local custom_action_id + local shared_scs_id + + create_global_action custom_action_id "$custom_action_name" "${custom_action_labels[@]}" + create_global_scs shared_scs_id "$shared_scs" + + # These anchor subject mappings stay legacy/global. Their target namespace + # should be derived from the referenced attribute value during migration. + local ignored_mapping_id + create_legacy_subject_mapping ignored_mapping_id "$ATTR_A_VAL_1_ID" "$GLOBAL_READ_ID" "$shared_scs_id" + # TODO(DSPX-2717): Replace the custom-action namespace anchor with a + # registered-resource or obligation-trigger fixture once those scope tests + # land, so action-scope coverage is not driven entirely by subject mappings. + create_legacy_subject_mapping ignored_mapping_id "$ATTR_A_VAL_2_ID" "$custom_action_id" "$shared_scs_id" + create_legacy_subject_mapping ignored_mapping_id "$ATTR_B_VAL_1_ID" "$GLOBAL_READ_ID" "$shared_scs_id" + + local output_file="${MIGRATION_OUTPUT_DIR}/actions-plan.json" + + run_namespaced_policy_commit "actions" "$output_file" + assert_success + + assert_action_target_count "$output_file" "read" 2 + assert_standard_action_resolved_in_namespace "$output_file" "read" "$NS_A_ID" "$NS_A_FQN" + assert_standard_action_resolved_in_namespace "$output_file" "read" "$NS_B_ID" "$NS_B_FQN" + + assert_action_target_count "$output_file" "$custom_action_name" 1 + source_action_id=$(action_plan_source_id "$output_file" "$custom_action_name") + assert_equal "$source_action_id" "$custom_action_id" + assert_custom_action_created_in_namespace "$output_file" "$custom_action_name" "$custom_action_id" "$NS_A_ID" "$NS_A_FQN" + assert_action_target_absent "$output_file" "$custom_action_name" "$NS_B_FQN" + + assert_legacy_custom_action_still_exists "$custom_action_id" "$custom_action_name" + assert_no_subject_mappings_in_namespace "$NS_A_ID" + assert_no_subject_mappings_in_namespace "$NS_B_ID" + + # Re-running the same migration should be idempotent. Custom action targets + # should now be marked already_migrated, while standard actions still resolve + # as existing_standard. + local rerun_output_file="${MIGRATION_OUTPUT_DIR}/actions-rerun-plan.json" + local custom_action_target_id + # Get the created ids of the objects from the initial run's output file. + custom_action_target_id=$(action_plan_target_effective_id "$output_file" "$custom_action_name" "$NS_A_FQN") + + run_namespaced_policy_commit "actions" "$rerun_output_file" + assert_success + + assert_action_target_count "$rerun_output_file" "read" 2 + assert_standard_action_resolved_in_namespace "$rerun_output_file" "read" "$NS_A_ID" "$NS_A_FQN" + assert_standard_action_resolved_in_namespace "$rerun_output_file" "read" "$NS_B_ID" "$NS_B_FQN" + assert_action_target_count "$rerun_output_file" "$custom_action_name" 1 + assert_action_already_migrated_in_namespace "$rerun_output_file" "$custom_action_name" "$NS_A_ID" "$NS_A_FQN" "$custom_action_target_id" + assert_action_target_absent "$rerun_output_file" "$custom_action_name" "$NS_B_FQN" + assert_no_subject_mappings_in_namespace "$NS_A_ID" + assert_no_subject_mappings_in_namespace "$NS_B_ID" +} + +# Asserts SCS-scope migration creates missing namespaced SCS targets, reuses an +# already-migrated canonical target when present, preserves subject_sets and +# metadata, does not create namespaced subject mappings as a side effect, and +# is idempotent on rerun. +@test "migrate namespaced-policy subject-condition-sets creates single-namespace targets and reuses existing fanout targets" { + local fanout_scs='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["'"${TEST_PREFIX}"'-shared"],"subject_external_selector_value":".org.name"}],"boolean_operator":1}]}]' + local single_namespace_scs='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["'"${TEST_PREFIX}"'-a-only"],"subject_external_selector_value":".team.name"}],"boolean_operator":1}]}]' + local fanout_scs_labels=(--label "test_case=scs" --label "fixture=${TEST_PREFIX}-fanout-scs") + local single_namespace_scs_labels=(--label "test_case=scs" --label "fixture=${TEST_PREFIX}-single-scs") + local fanout_scs_id + local single_namespace_scs_id + local existing_fanout_ns_b_scs_id + + create_global_scs fanout_scs_id "$fanout_scs" "${fanout_scs_labels[@]}" + create_global_scs single_namespace_scs_id "$single_namespace_scs" "${single_namespace_scs_labels[@]}" + create_namespaced_scs existing_fanout_ns_b_scs_id "$NS_B_ID" "$fanout_scs" + + local ignored_mapping_id + create_legacy_subject_mapping ignored_mapping_id "$ATTR_A_VAL_1_ID" "$GLOBAL_READ_ID" "$fanout_scs_id" + create_legacy_subject_mapping ignored_mapping_id "$ATTR_B_VAL_1_ID" "$GLOBAL_READ_ID" "$fanout_scs_id" + create_legacy_subject_mapping ignored_mapping_id "$ATTR_A_VAL_2_ID" "$GLOBAL_READ_ID" "$single_namespace_scs_id" + + local output_file="${MIGRATION_OUTPUT_DIR}/subject-condition-sets-plan.json" + + run_namespaced_policy_commit "subject-condition-sets" "$output_file" + assert_success + + assert_scs_target_count "$output_file" "$fanout_scs_id" 2 + assert_scs_created_in_namespace "$output_file" "$fanout_scs_id" "$NS_A_ID" "$NS_A_FQN" + assert_scs_already_migrated_in_namespace "$output_file" "$fanout_scs_id" "$NS_B_ID" "$NS_B_FQN" "$existing_fanout_ns_b_scs_id" + + assert_scs_target_count "$output_file" "$single_namespace_scs_id" 1 + assert_scs_created_in_namespace "$output_file" "$single_namespace_scs_id" "$NS_A_ID" "$NS_A_FQN" + assert_scs_target_absent "$output_file" "$single_namespace_scs_id" "$NS_B_FQN" + + assert_legacy_scs_still_exists "$fanout_scs_id" + assert_legacy_scs_still_exists "$single_namespace_scs_id" + assert_no_subject_mappings_in_namespace "$NS_A_ID" + assert_no_subject_mappings_in_namespace "$NS_B_ID" + + # Re-running the same migration should be idempotent. The previously created + # SCS targets should now be marked already_migrated, and the pre-existing + # canonical target should continue to resolve as already_migrated. + local rerun_output_file="${MIGRATION_OUTPUT_DIR}/subject-condition-sets-rerun-plan.json" + local fanout_ns_a_target_id + local single_namespace_target_id + # Get the created ids of the objects from the initial run's output file. + fanout_ns_a_target_id=$(scs_plan_target_effective_id "$output_file" "$fanout_scs_id" "$NS_A_FQN") + single_namespace_target_id=$(scs_plan_target_effective_id "$output_file" "$single_namespace_scs_id" "$NS_A_FQN") + + run_namespaced_policy_commit "subject-condition-sets" "$rerun_output_file" + assert_success + + assert_scs_target_count "$rerun_output_file" "$fanout_scs_id" 2 + assert_scs_already_migrated_in_namespace "$rerun_output_file" "$fanout_scs_id" "$NS_A_ID" "$NS_A_FQN" "$fanout_ns_a_target_id" + assert_scs_already_migrated_in_namespace "$rerun_output_file" "$fanout_scs_id" "$NS_B_ID" "$NS_B_FQN" "$existing_fanout_ns_b_scs_id" + assert_scs_target_count "$rerun_output_file" "$single_namespace_scs_id" 1 + assert_scs_already_migrated_in_namespace "$rerun_output_file" "$single_namespace_scs_id" "$NS_A_ID" "$NS_A_FQN" "$single_namespace_target_id" + assert_scs_target_absent "$rerun_output_file" "$single_namespace_scs_id" "$NS_B_FQN" + assert_no_subject_mappings_in_namespace "$NS_A_ID" + assert_no_subject_mappings_in_namespace "$NS_B_ID" +} + +# Asserts subject-mapping migration creates namespaced mappings, rewrites action +# and SCS dependencies to the correct target IDs, preserves source metadata on +# the migrated subject mappings, and is idempotent on rerun. +@test "migrate namespaced-policy subject-mappings rewrites action and scs dependencies" { + local custom_action_name="${TEST_PREFIX}-download" + local sm_a_scs='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["'"${TEST_PREFIX}"'-sm-a"],"subject_external_selector_value":".org.name"}],"boolean_operator":1}]}]' + local sm_b_scs='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["'"${TEST_PREFIX}"'-sm-b"],"subject_external_selector_value":".team.name"}],"boolean_operator":1}]}]' + local custom_action_labels=(--label "test_case=subject-mappings" --label "fixture=${TEST_PREFIX}-custom-action") + local sm_a_scs_labels=(--label "test_case=subject-mappings" --label "fixture=${TEST_PREFIX}-sm-a-scs") + local sm_b_scs_labels=(--label "test_case=subject-mappings" --label "fixture=${TEST_PREFIX}-sm-b-scs") + local mapping_a_labels=(--label "test_case=subject-mappings" --label "fixture=${TEST_PREFIX}-mapping-a") + local mapping_b_labels=(--label "test_case=subject-mappings" --label "fixture=${TEST_PREFIX}-mapping-b") + local custom_action_id + local sm_a_scs_id + local sm_b_scs_id + local mapping_a_id + local mapping_b_id + + create_global_action custom_action_id "$custom_action_name" "${custom_action_labels[@]}" + create_global_scs sm_a_scs_id "$sm_a_scs" "${sm_a_scs_labels[@]}" + create_global_scs sm_b_scs_id "$sm_b_scs" "${sm_b_scs_labels[@]}" + + create_legacy_subject_mapping mapping_a_id "$ATTR_A_VAL_1_ID" "$custom_action_id" "$sm_a_scs_id" "${mapping_a_labels[@]}" + create_legacy_subject_mapping mapping_b_id "$ATTR_B_VAL_1_ID" "$GLOBAL_READ_ID" "$sm_b_scs_id" "${mapping_b_labels[@]}" + + local output_file="${MIGRATION_OUTPUT_DIR}/subject-mappings-plan.json" + + run_namespaced_policy_commit "subject-mappings" "$output_file" + assert_success + + assert_subject_mapping_target_count "$output_file" "$mapping_a_id" 1 + assert_subject_mapping_created_in_namespace "$output_file" "$mapping_a_id" "$NS_A_ID" "$NS_A_FQN" "$ATTR_A_VAL_1_ID" "$custom_action_name" "$custom_action_id" "create" 1 "$sm_a_scs_id" 1 + + assert_subject_mapping_target_count "$output_file" "$mapping_b_id" 1 + assert_subject_mapping_created_in_namespace "$output_file" "$mapping_b_id" "$NS_B_ID" "$NS_B_FQN" "$ATTR_B_VAL_1_ID" "read" "$GLOBAL_READ_ID" "existing_standard" 1 "$sm_b_scs_id" 1 + + assert_legacy_subject_mapping_still_exists "$ATTR_A_VAL_1_ID" "$mapping_a_id" + assert_legacy_subject_mapping_still_exists "$ATTR_B_VAL_1_ID" "$mapping_b_id" + + # Re-running the same migration should be idempotent. The custom action, + # migrated SCS targets, and migrated subject mappings should all resolve as + # already_migrated on the second pass. Standard read remains existing_standard. + local rerun_output_file="${MIGRATION_OUTPUT_DIR}/subject-mappings-rerun-plan.json" + local custom_action_target_id + local sm_a_scs_target_id + local sm_b_scs_target_id + local mapping_a_target_id + local mapping_b_target_id + # Get the created ids of the objects from the initial run's output file. + custom_action_target_id=$(action_plan_target_effective_id "$output_file" "$custom_action_name" "$NS_A_FQN") + sm_a_scs_target_id=$(scs_plan_target_effective_id "$output_file" "$sm_a_scs_id" "$NS_A_FQN") + sm_b_scs_target_id=$(scs_plan_target_effective_id "$output_file" "$sm_b_scs_id" "$NS_B_FQN") + mapping_a_target_id=$(subject_mapping_plan_target_effective_id "$output_file" "$mapping_a_id" "$NS_A_FQN") + mapping_b_target_id=$(subject_mapping_plan_target_effective_id "$output_file" "$mapping_b_id" "$NS_B_FQN") + + run_namespaced_policy_commit "subject-mappings" "$rerun_output_file" + assert_success + + assert_action_target_count "$rerun_output_file" "$custom_action_name" 1 + assert_action_already_migrated_in_namespace "$rerun_output_file" "$custom_action_name" "$NS_A_ID" "$NS_A_FQN" "$custom_action_target_id" + assert_action_target_count "$rerun_output_file" "read" 1 + assert_standard_action_resolved_in_namespace "$rerun_output_file" "read" "$NS_B_ID" "$NS_B_FQN" + assert_scs_target_count "$rerun_output_file" "$sm_a_scs_id" 1 + assert_scs_already_migrated_in_namespace "$rerun_output_file" "$sm_a_scs_id" "$NS_A_ID" "$NS_A_FQN" "$sm_a_scs_target_id" + assert_scs_target_count "$rerun_output_file" "$sm_b_scs_id" 1 + assert_scs_already_migrated_in_namespace "$rerun_output_file" "$sm_b_scs_id" "$NS_B_ID" "$NS_B_FQN" "$sm_b_scs_target_id" + assert_subject_mapping_target_count "$rerun_output_file" "$mapping_a_id" 1 + assert_subject_mapping_already_migrated_in_namespace "$rerun_output_file" "$mapping_a_id" "$NS_A_ID" "$NS_A_FQN" "$mapping_a_target_id" + assert_subject_mapping_target_count "$rerun_output_file" "$mapping_b_id" 1 + assert_subject_mapping_already_migrated_in_namespace "$rerun_output_file" "$mapping_b_id" "$NS_B_ID" "$NS_B_FQN" "$mapping_b_target_id" +} diff --git a/otdfctl/e2e/namespaces.bats b/otdfctl/e2e/namespaces.bats new file mode 100755 index 0000000000..f39e1344d4 --- /dev/null +++ b/otdfctl/e2e/namespaces.bats @@ -0,0 +1,306 @@ +#!/usr/bin/env bats + +# Tests for namespaces +load "${BATS_LIB_PATH}/bats-support/load.bash" +load "${BATS_LIB_PATH}/bats-assert/load.bash" +load "otdfctl-utils.sh" + +setup_file() { + export WITH_CREDS='--with-client-creds-file ./creds.json' + export HOST='--host http://localhost:8080' + + # Create the namespace to be used by other tests + + export NS_NAME="creating-test-ns.net" + export NS_NAME_UPDATE="updated-test-ns.net" + export NS_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes namespaces create -n "$NS_NAME" --json | jq -r '.id') + export NS_ID_FLAG="--id $NS_ID" + + export KAS_URI="https://test-kas-for-namespace.com" + export KAS_REG_ID=$(./otdfctl $HOST $WITH_CREDS policy kas-registry create --uri "$KAS_URI" --json | jq -r '.id') + # Generate a valid RSA public key and base64 encode (single-line) + export PEM_B64=$(openssl genrsa 2048 2>/dev/null | openssl rsa -pubout 2>/dev/null | base64 | tr -d '\n') + export KAS_KEY_ID="test-key-for-namespace" + export KAS_KEY_SYSTEM_ID=$(./otdfctl $HOST $WITH_CREDS policy kas-registry key create --kas "$KAS_REG_ID" --key-id "$KAS_KEY_ID" --algorithm "rsa:2048" --mode "public_key" --public-key-pem "${PEM_B64}" --json | jq -r '.key.id') + export PEM=$(echo "$PEM_B64" | base64 -d) +} + +setup() { + # invoke binary with credentials under 'policy attributes namespaces' + run_otdfctl_ns() { + run sh -c "./otdfctl $HOST $WITH_CREDS policy attributes namespaces $*" + } + # invoke binary with credentials under 'policy namespaces' (direct path) + run_otdfctl_nsd() { + run sh -c "./otdfctl $HOST $WITH_CREDS policy namespaces $*" + } +} + +teardown_file() { + ./otdfctl $HOST $WITH_CREDS policy attributes namespace unsafe delete --id "$NS_ID" --force + + delete_all_keys_in_kas "$KAS_REG_ID" + delete_kas_registry "$KAS_REG_ID" + + # clear out all test env vars + unset HOST WITH_CREDS NS_NAME NS_FQN NS_ID NS_ID_FLAG KAS_REG_ID KAS_KEY_ID KAS_URI PEM_B64 PEM KAS_KEY_SYSTEM_ID +} + +@test "Create a namespace - Good" { + run_otdfctl_ns create --name throwaway.test + assert_output --partial "SUCCESS" + assert_line --regexp "Name.*throwaway.test" + assert_output --partial "Id" + assert_output --partial "Created At" + assert_line --partial "Updated At" + + # cleanup + created_id=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + run_otdfctl_ns unsafe delete --id $created_id --force +} + +@test "Create a namespace - Bad" { + # bad namespace names + run_otdfctl_ns create --name no_domain_extension + assert_failure + run_otdfctl_ns create --name -first-char-hyphen.co + assert_failure + run_otdfctl_ns create --name last-char-hyphen-.co + assert_failure + + # missing flag + run_otdfctl_ns create + assert_failure + assert_output --partial "Flag '--name' is required" + + # conflict + run_otdfctl_ns create -n "$NS_NAME" + assert_failure + assert_output --partial "already_exists" +} + +@test "Get a namespace - Good" { + run_otdfctl_ns get "$NS_ID_FLAG" + assert_success + assert_line --regexp "Id.*$NS_ID" + assert_line --regexp "Name.*$NS_NAME" + + run_otdfctl_ns get "$NS_ID_FLAG" --json + assert_success + [ "$(echo "$output" | jq -r '.id')" = "$NS_ID" ] + [ "$(echo "$output" | jq -r '.name')" = "$NS_NAME" ] +} + +@test "Get a namespace - Bad" { + run_otdfctl_ns get + assert_failure + assert_output --partial "Flag '--id' is required" + + run_otdfctl_ns get --id 'example.com' + assert_failure + assert_output --partial "Flag '--id' received value 'example.com' must be a valid UUID" + + run_otdfctl_ns get --id 'demo.com' --json + assert_failure + assert_output --partial "Flag '--id' received value 'demo.com' must be a valid UUID" +} + +@test "List namespaces - when active" { + run_otdfctl_ns list --json + echo $output | jq --arg id "$NS_ID" '.namespaces[] | select(.id == $id)' + assert_not_equal $(echo $output | jq '.pagination') "null" + + run_otdfctl_ns list --state inactive --json + refute_output --partial "$NS_ID" + assert_not_equal $(echo $output | jq '.pagination') "null" + + run_otdfctl_ns list --state active + assert_output --partial "$NS_ID" + assert_output --partial "Total" + assert_line --regexp "Current Offset.*0" +} + +@test "Update namespace - Safe" { + # extend labels + run_otdfctl_ns update "$NS_ID_FLAG" -l key=value --label test=true + assert_success + assert_line --regexp "Id.*$NS_ID" + assert_line --regexp "Name.*$NS_NAME" + assert_line --regexp "Labels.*key: value" + assert_line --regexp "Labels.*test: true" + + # force replace labels + run_otdfctl_ns update "$NS_ID_FLAG" -l key=other --force-replace-labels + assert_success + assert_line --regexp "Id.*$NS_ID" + assert_line --regexp "Name.*$NS_NAME" + assert_line --regexp "Labels.*key: other" + refute_output --regexp "Labels.*key: value" + refute_output --regexp "Labels.*test: true" +} + +@test "Update namespace - Unsafe" { + run_otdfctl_ns unsafe update "$NS_ID_FLAG" -n "$NS_NAME_UPDATE" --force + assert_success + assert_line --regexp "Id.*$NS_ID" + run_otdfctl_ns get "$NS_ID_FLAG" + assert_line --regexp "Name.*$NS_NAME_UPDATE" + refute_output --regexp "Name.*$NS_NAME" +} + +@test "Assign/Remove KAS key from namespace - With Namespace ID" { + run_otdfctl_ns key assign --namespace "$NS_ID" --key-id "$KAS_KEY_SYSTEM_ID" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.namespace_id')" "$NS_ID" + assert_equal "$(echo "$output" | jq -r '.key_id')" "$KAS_KEY_SYSTEM_ID" + + run_otdfctl_ns get --id "$NS_ID" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.id')" "$NS_ID" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].kas_uri')" "$KAS_URI" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].kas_id')" "$KAS_REG_ID" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].public_key.kid')" "$KAS_KEY_ID" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].public_key.pem')" "$PEM" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].public_key.algorithm')" 1 + + run_otdfctl_ns key remove --namespace "$NS_ID" --key-id "$KAS_KEY_SYSTEM_ID" --json + assert_success + + run_otdfctl_ns get --id "$NS_ID" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.id')" "$NS_ID" + assert_equal "$(echo "$output" | jq -r '.kas_keys | length')" 0 +} + +@test "Assign/Remove KAS key from namespace - With Namespace FQN" { + run_otdfctl_ns get --id "$NS_ID" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.id')" "$NS_ID" + assert_equal "$(echo "$output" | jq -r '.kas_keys | length')" 0 + NS_FQN=$(echo "$output" | jq -r '.fqn') + + run_otdfctl_ns key assign --namespace "$NS_FQN" --key-id "$KAS_KEY_SYSTEM_ID" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.namespace_id')" "$NS_ID" + assert_equal "$(echo "$output" | jq -r '.key_id')" "$KAS_KEY_SYSTEM_ID" + + run_otdfctl_ns get --id "$NS_ID" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.id')" "$NS_ID" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].kas_uri')" "$KAS_URI" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].kas_id')" "$KAS_REG_ID" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].public_key.kid')" "$KAS_KEY_ID" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].public_key.pem')" "$PEM" + assert_equal "$(echo "$output" | jq -r '.kas_keys[0].public_key.algorithm')" 1 + + run_otdfctl_ns key remove --namespace "$NS_ID" --key-id "$KAS_KEY_SYSTEM_ID" --json + assert_success + + run_otdfctl_ns get --id "$NS_ID" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.id')" "$NS_ID" + assert_equal "$(echo "$output" | jq -r '.kas_keys | length')" 0 +} + +@test "KAS key assignment error handling - namespace" { + # Test with non-existent namespace ID + run_otdfctl_ns key assign --namespace "00000000-0000-0000-0000-000000000000" --key-id "$KAS_KEY_SYSTEM_ID" + assert_failure + assert_output --partial "ERROR" + + # Test with missing required flags + run_otdfctl_ns key assign --namespace "$NS_ID" + assert_failure + assert_output --partial "Flag '--key-id' is required" + + run_otdfctl_ns key assign --key-id "$KAS_KEY_SYSTEM_ID" + assert_failure + assert_output --partial "Flag '--namespace' is required" +} + +@test "Deactivate namespace" { + run_otdfctl_ns deactivate "$NS_ID_FLAG" --force + assert_success + assert_line --regexp "Id.*$NS_ID" + assert_line --regexp "Name.*$NS_NAME_UPDATE" +} + +@test "List namespaces - when inactive" { + run_otdfctl_ns list --json + echo $output | jq --arg id "$NS_ID" '.namespaces[] | select(.id == $id)' + assert_not_equal $(echo $output | jq '.pagination') "null" + + # json + run_otdfctl_ns list --state inactive --json + echo $output | assert_output --partial "$NS_ID" + assert_not_equal $(echo $output | jq '.pagination') "null" + + run_otdfctl_ns list --state active --json + echo $output | refute_output --partial "$NS_ID" + assert_not_equal $(echo $output | jq '.pagination') "null" + # table + run_otdfctl_ns list --state inactive + echo $output | assert_output --partial "$NS_ID" + + run_otdfctl_ns list --state active + echo $output | refute_output --partial "$NS_ID" +} + +@test "Unsafe reactivate namespace" { + run_otdfctl_ns unsafe reactivate "$NS_ID_FLAG" --force + assert_success + assert_line --regexp "Id.*$NS_ID" +} + +@test "List namespaces - when reactivated" { + run_otdfctl_ns list --json + echo $output | jq --arg id "$NS_ID" '.namespaces[] | select(.id == $id)' + assert_not_equal $(echo $output | jq '.pagination') "null" + + run_otdfctl_ns list --state inactive --json + echo $output | refute_output --partial "$NS_ID" + assert_not_equal $(echo $output | jq '.pagination') "null" + + run_otdfctl_ns list --state active + echo $output | assert_output --partial "$NS_ID" +} + +@test "Unsafe delete namespace" { + run_otdfctl_ns unsafe delete "$NS_ID_FLAG" --force + assert_success + assert_line --regexp "Id.*$NS_ID" + assert_line --regexp "Name.*$NS_NAME_UPDATE" +} + +@test "List namespaces - when deleted" { + run_otdfctl_ns list --json + echo $output | refute_output --partial "$NS_ID" + assert_not_equal $(echo $output | jq '.pagination') "null" + + run_otdfctl_ns list --state inactive --json + echo $output | refute_output --partial "$NS_ID" + assert_not_equal $(echo $output | jq '.pagination') "null" + + run_otdfctl_ns list --state active + echo $output | refute_output --partial "$NS_ID" +} + +# ── policy namespaces (direct path) ────────────────────────────────────────── + +@test "Direct path: policy namespaces commands are accessible" { + run_otdfctl_nsd create --name direct-path-test.net --json + assert_success + DIRECT_NS_ID=$(echo "$output" | jq -r '.id') + + run_otdfctl_nsd get --id "$DIRECT_NS_ID" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.name')" "direct-path-test.net" + + run_otdfctl_nsd list --json + assert_success + assert_output --partial "$DIRECT_NS_ID" + + # cleanup + run_otdfctl_nsd unsafe delete --id "$DIRECT_NS_ID" --force + assert_success +} diff --git a/otdfctl/e2e/obligations.bats b/otdfctl/e2e/obligations.bats new file mode 100644 index 0000000000..13899736e8 --- /dev/null +++ b/otdfctl/e2e/obligations.bats @@ -0,0 +1,1033 @@ +#!/usr/bin/env bats + +# Tests for obligations + +setup_file() { + export WITH_CREDS='--with-client-creds-file ./creds.json' + export HOST='--host http://localhost:8080' + + # create attribute value to be used in obligation values tests + export NS_NAME="test-obl.org" + export NS_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes namespaces create --name "$NS_NAME" --json | jq -r '.id') + + # create obligation used in obligation values tests + export OBL_NAME="test_obl_for_values" + export OBL_ID=$(./otdfctl $HOST $WITH_CREDS policy obligations create --name "$OBL_NAME" --namespace "$NS_ID" --json | jq -r '.id') + + # shared triggers file for tests + export SHARED_TRIGGERS_FILE="/tmp/shared_test_triggers.json" + + # create shared actions for tests + export ACTION_1_NAME="test_action_1" + export ACTION_1_ID=$(./otdfctl $HOST $WITH_CREDS policy actions create --name "$ACTION_1_NAME" --namespace "$NS_ID" --json | jq -r '.id') + export ACTION_2_NAME="test_action_2" + export ACTION_2_ID=$(./otdfctl $HOST $WITH_CREDS policy actions create --name "$ACTION_2_NAME" --namespace "$NS_ID" --json | jq -r '.id') + + # create shared attributes for tests + export ATTR_NAME="test_attr_for_triggers" + export ATTR_VAL_NAME="test_val_for_triggers" + attr_result=$(./otdfctl $HOST $WITH_CREDS policy attributes create --name "$ATTR_NAME" --namespace "$NS_ID" --rule "HIERARCHY" -v "$ATTR_VAL_NAME" --json) + export ATTR_ID=$(echo "$attr_result" | jq -r '.id') + export ATTR_VAL_ID=$(echo "$attr_result" | jq -r '.values[0].id') + export ATTR_VAL_FQN=$(echo "$attr_result" | jq -r '.values[0].fqn') + + export ATTR_2_NAME="test_attr_for_triggers_2" + export ATTR_2_VAL_NAME="test_val_for_triggers_2" + attr_2_result=$(./otdfctl $HOST $WITH_CREDS policy attributes create --name "$ATTR_2_NAME" --namespace "$NS_ID" --rule "HIERARCHY" -v "$ATTR_2_VAL_NAME" --json) + export ATTR_2_ID=$(echo "$attr_2_result" | jq -r '.id') + export ATTR_2_VAL_ID=$(echo "$attr_2_result" | jq -r '.values[0].id') + export ATTR_2_VAL_FQN=$(echo "$attr_2_result" | jq -r '.values[0].fqn') + + # Create namespaces and attributes for list triggers tests + export LIST_NS_1_NAME="list-test-ns1.org" + export LIST_NS_1_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes namespaces create --name "$LIST_NS_1_NAME" --json | jq -r '.id') + export LIST_NS_1_FQN="https://$LIST_NS_1_NAME" + + export LIST_NS_2_NAME="list-test-ns2.org" + export LIST_NS_2_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes namespaces create --name "$LIST_NS_2_NAME" --json | jq -r '.id') + export LIST_NS_2_FQN="https://$LIST_NS_2_NAME" + + # Create actions in each list namespace for trigger creation + export LIST_ACTION_1_NAME="list_test_action_1" + export LIST_ACTION_1_ID=$(./otdfctl $HOST $WITH_CREDS policy actions create --name "$LIST_ACTION_1_NAME" --namespace "$LIST_NS_1_ID" --json | jq -r '.id') + + export LIST_ACTION_2_NAME="list_test_action_2" + export LIST_ACTION_2_ID=$(./otdfctl $HOST $WITH_CREDS policy actions create --name "$LIST_ACTION_2_NAME" --namespace "$LIST_NS_2_ID" --json | jq -r '.id') + + # Create attributes for list triggers tests + # Namespace 1 attributes + list_attr_1_result=$(./otdfctl $HOST $WITH_CREDS policy attributes create --name "list_test_attr" --namespace "$LIST_NS_1_ID" --rule "HIERARCHY" -v "val1" --json) + export LIST_ATTR_1_ID=$(echo "$list_attr_1_result" | jq -r '.id') + export LIST_ATTR_1_VAL_1_ID=$(echo "$list_attr_1_result" | jq -r '.values[0].id') + export LIST_ATTR_1_VAL_1_FQN=$(echo "$list_attr_1_result" | jq -r '.values[0].fqn') + + # Namespace 2 attributes + list_attr_2_result=$(./otdfctl $HOST $WITH_CREDS policy attributes create --name "list_test_attr" --namespace "$LIST_NS_2_ID" --rule "HIERARCHY" -v "val1" --json) + export LIST_ATTR_2_ID=$(echo "$list_attr_2_result" | jq -r '.id') + export LIST_ATTR_2_VAL_1_ID=$(echo "$list_attr_2_result" | jq -r '.values[0].id') + export LIST_ATTR_2_VAL_1_FQN=$(echo "$list_attr_2_result" | jq -r '.values[0].fqn') + + # Set global vars for list triggers tests that will get populated in setup_triggers_test_data + export CLIENT_ID_LIST="test-client-list" +} + +setup() { + load "${BATS_LIB_PATH}/bats-support/load.bash" + load "${BATS_LIB_PATH}/bats-assert/load.bash" + + # invoke binary with credentials + run_otdfctl_obl () { + run sh -c "./otdfctl $HOST $WITH_CREDS policy obligations $*" + } + run_otdfctl_obl_values () { + run sh -c "./otdfctl $HOST $WITH_CREDS policy obligations values $*" + } + run_otdfctl_obl_triggers () { + run sh -c "./otdfctl $HOST $WITH_CREDS policy obligations triggers $*" + } + + run_otdfctl_action () { + run sh -c "./otdfctl $HOST $WITH_CREDS policy actions $*" + } + + run_otdfctl_attr() { + run sh -c "./otdfctl $HOST $WITH_CREDS policy attributes $*" + } + + # Cleanup helper functions + cleanup_obligation_value() { + local value_id="$1" + if [ -n "$value_id" ] && [ "$value_id" != "null" ]; then + run_otdfctl_obl_values delete --id "$value_id" --force + fi + } + + cleanup_action() { + local action_id="$1" + if [ -n "$action_id" ] && [ "$action_id" != "null" ]; then + run_otdfctl_action delete --id "$action_id" --force + fi + } + + cleanup_attribute() { + local attr_id="$1" + if [ -n "$attr_id" ] && [ "$attr_id" != "null" ]; then + run_otdfctl_attr unsafe delete --id "$attr_id" --force + fi + } + + cleanup_trigger() { + local trigger_id="$1" + if [ -n "$trigger_id" ] && [ "$trigger_id" != "null" ]; then + run_otdfctl_obl_triggers delete --id "$trigger_id" --force + fi + } + + cleanup_temp_file() { + local file_path="$1" + if [ -n "$file_path" ] && [ -f "$file_path" ]; then + rm -f "$file_path" + fi + } + + # Validate triggers in JSON response + validate_triggers() { + local json_output="$1" + local expected_count="$2" + shift 2 + local expected_triggers=("$@") # Array of expected trigger specs: "attr_val_id;attr_val_fqn;action_id;action_name;client_id" + + # Validate trigger count + local actual_count=$(echo "$json_output" | jq -r '.triggers | length') + assert_equal "$actual_count" "$expected_count" + + # Validate each expected trigger exists in the response + for expected_trigger in "${expected_triggers[@]}"; do + IFS=';' read -ra TRIGGER_SPEC <<< "$expected_trigger" + local exp_attr_val_id="${TRIGGER_SPEC[0]}" + local exp_attr_val_fqn="${TRIGGER_SPEC[1]}" + local exp_action_id="${TRIGGER_SPEC[2]}" + local exp_action_name="${TRIGGER_SPEC[3]}" + local exp_client_id="${TRIGGER_SPEC[4]}" + local exp_obl_val_id="${TRIGGER_SPEC[5]}" + local exp_obl_val_fqn="${TRIGGER_SPEC[6]}" + + # Find if this expected trigger exists in the response + local found=false + for ((i=0; i 0 then .triggers[$i].context[0].pep.client_id // \"\" else \"\" end") + if [ "$actual_client_id" != "$exp_client_id" ]; then + match=false + fi + fi + + # Check obligation value ID if specified + if [ "$match" = true ] && [ -n "$exp_obl_val_id" ] && [ "$exp_obl_val_id" != "null" ]; then + local actual_obl_val_id=$(echo "$json_output" | jq -r ".triggers[$i].obligation_value.id") + if [ "$actual_obl_val_id" != "$exp_obl_val_id" ]; then + match=false + fi + fi + + # Check obligation value FQN if specified + if [ "$match" = true ] && [ -n "$exp_obl_val_fqn" ] && [ "$exp_obl_val_fqn" != "null" ]; then + local actual_obl_val_fqn=$(echo "$json_output" | jq -r ".triggers[$i].obligation_value.fqn") + if [ "$actual_obl_val_fqn" != "$exp_obl_val_fqn" ]; then + match=false + fi + fi + + if [ "$match" = true ]; then + found=true + break + fi + done + + # Assert that we found this expected trigger + if [ "$found" = false ]; then + echo "Expected trigger not found: attr_val_id=$exp_attr_val_id, attr_val_fqn=$exp_attr_val_fqn, action_id=$exp_action_id, action_name=$exp_action_name, client_id=$exp_client_id, obl_val_id=$exp_obl_val_id, obl_val_fqn=$exp_obl_val_fqn" + return 1 + fi + done + } + + setup_triggers_test_data() { + export LIST_OBL_1_NAME="list_test_obl" + export LIST_OBL_1_VAL="list_test_val" + export LIST_OBL_1_FQN="https://$LIST_NS_1_NAME/obl/$LIST_OBL_1_NAME" + export LIST_OBL_VAL_1_FQN="$LIST_OBL_1_FQN/value/$LIST_OBL_1_VAL" + export LIST_OBL_1_ID="" + export LIST_OBL_2_NAME="list_test_obl" + export LIST_OBL_2_VAL="list_test_val" + export LIST_OBL_2_FQN="https://$LIST_NS_2_NAME/obl/$LIST_OBL_2_NAME" + export LIST_OBL_VAL_2_FQN="$LIST_OBL_2_FQN/value/$LIST_OBL_2_VAL" + export LIST_OBL_2_ID="" + + run sh -c "./otdfctl $HOST $WITH_CREDS policy obligations get --fqn $LIST_OBL_1_FQN --json" + if [ $status -ne 0 ]; then + run sh -c "./otdfctl $HOST $WITH_CREDS policy obligations create --name "$LIST_OBL_1_NAME" --namespace "$LIST_NS_1_ID" --json" + assert_success + export LIST_OBL_1_ID=$(echo "$output" | jq -r '.id') + + run sh -c "./otdfctl $HOST $WITH_CREDS policy obligations values create --obligation "$LIST_OBL_1_ID" --value "$LIST_OBL_1_VAL" --json" + assert_success + export LIST_OBL_VAL_1_ID=$(echo "$output" | jq -r '.id') + + run sh -c "./otdfctl $HOST $WITH_CREDS policy obligations triggers create --attribute-value "$LIST_ATTR_1_VAL_1_ID" --action "$LIST_ACTION_1_ID" --obligation-value "$LIST_OBL_VAL_1_ID" --client-id "$CLIENT_ID_LIST" --json" + assert_success + export LIST_TRIGGER_1_ID=$(echo "$output" | jq -r '.id') + else + export LIST_OBL_1_ID=$(echo "$output" | jq -r '.id') + export LIST_OBL_VAL_1_ID=$(echo "$output" | jq -r '.values[0].id') + export LIST_TRIGGER_1_ID=$(echo "$output" | jq -r '.values[0].triggers[0].id') + fi + + run sh -c "./otdfctl $HOST $WITH_CREDS policy obligations get --fqn $LIST_OBL_2_FQN --json" + if [ $status -ne 0 ]; then + run sh -c "./otdfctl $HOST $WITH_CREDS policy obligations create --name "$LIST_OBL_2_NAME" --namespace "$LIST_NS_2_ID" --json" + assert_success + export LIST_OBL_2_ID=$(echo "$output" | jq -r '.id') + + run sh -c "./otdfctl $HOST $WITH_CREDS policy obligations values create --obligation "$LIST_OBL_2_ID" --value "$LIST_OBL_2_VAL" --json" + assert_success + export LIST_OBL_VAL_2_ID=$(echo "$output" | jq -r '.id') + + run sh -c "./otdfctl $HOST $WITH_CREDS policy obligations triggers create --attribute-value "$LIST_ATTR_2_VAL_1_ID" --action "$LIST_ACTION_2_ID" --obligation-value "$LIST_OBL_VAL_2_ID" --client-id "$CLIENT_ID_LIST" --json" + assert_success + export LIST_TRIGGER_2_ID=$(echo "$output" | jq -r '.id') + else + export LIST_OBL_2_ID=$(echo "$output" | jq -r '.id') + export LIST_OBL_VAL_2_ID=$(echo "$output" | jq -r '.values[0].id') + export LIST_TRIGGER_2_ID=$(echo "$output" | jq -r '.values[0].triggers[0].id') + fi + } + + validate_pagination() { + local json_output="$1" + local expected_offset="$2" + local expected_total="$3" + local expected_next_offset="$4" + assert_equal "$(echo "$json_output" | jq -r '.pagination.current_offset')" "$expected_offset" + assert_equal "$(echo "$json_output" | jq -r '.pagination.total')" "$expected_total" + assert_equal "$(echo "$json_output" | jq -r '.pagination.next_offset')" "$expected_next_offset" + } +} + +teardown_file() { + # remove the obligation used in obligation values tests + ./otdfctl $HOST $WITH_CREDS policy obligations delete --id "$OBL_ID" --force + + # remove shared actions + ./otdfctl $HOST $WITH_CREDS policy actions delete --id "$ACTION_1_ID" --force + ./otdfctl $HOST $WITH_CREDS policy actions delete --id "$ACTION_2_ID" --force + + # remove shared attributes + ./otdfctl $HOST $WITH_CREDS policy attributes unsafe delete --id "$ATTR_ID" --force + ./otdfctl $HOST $WITH_CREDS policy attributes unsafe delete --id "$ATTR_2_ID" --force + + # remove the namespace used in obligation values tests + ./otdfctl $HOST $WITH_CREDS policy attributes namespaces unsafe delete --id "$NS_ID" --force + + # remove list triggers test namespaces + ./otdfctl $HOST $WITH_CREDS policy actions delete --id "$LIST_ACTION_1_ID" --force + ./otdfctl $HOST $WITH_CREDS policy actions delete --id "$LIST_ACTION_2_ID" --force + ./otdfctl $HOST $WITH_CREDS policy attributes namespaces unsafe delete --id "$LIST_NS_1_ID" --force + ./otdfctl $HOST $WITH_CREDS policy attributes namespaces unsafe delete --id "$LIST_NS_2_ID" --force + + # cleanup shared triggers file + rm -f "$SHARED_TRIGGERS_FILE" + + # clear out all test env vars + unset HOST WITH_CREDS OBL_NAME OBL_ID NS_NAME NS_ID ACTION_1_NAME ACTION_1_ID ACTION_2_NAME ACTION_2_ID ATTR_NAME ATTR_VAL_NAME ATTR_ID ATTR_VAL_ID ATTR_VAL_FQN ATTR_2_NAME ATTR_2_VAL_NAME ATTR_2_ID ATTR_2_VAL_ID ATTR_2_VAL_FQN + unset CLIENT_ID_LIST LIST_NS_1_NAME LIST_NS_1_ID LIST_NS_1_FQN LIST_NS_2_NAME LIST_NS_2_ID LIST_NS_2_FQN + unset LIST_ACTION_1_NAME LIST_ACTION_1_ID LIST_ACTION_2_NAME LIST_ACTION_2_ID + unset LIST_ATTR_1_ID LIST_ATTR_1_VAL_1_ID LIST_ATTR_1_VAL_1_FQN LIST_ATTR_2_ID LIST_ATTR_2_VAL_1_ID LIST_ATTR_2_VAL_1_FQN +} + + +@test "Create a obligation - Good" { + run_otdfctl_obl create --name test_create_obl --namespace "$NS_ID" --json + assert_success + [ "$(echo "$output" | jq -r '.name')" = "test_create_obl" ] + [ -n "$(echo "$output" | jq -r '.id')" ] + [ -n "$(echo "$output" | jq -r '.created_at')" ] + [ -n "$(echo "$output" | jq -r '.updated_at')" ] + + # cleanup + created_id="$(echo "$output" | jq -r '.id')" + run_otdfctl_obl delete --id "$created_id" --force +} + +@test "Create a obligation - Bad" { + # bad obligation names + run_otdfctl_obl create --name ends_underscored_ --namespace "$NS_ID" + assert_failure + run_otdfctl_obl create --name -first-char-hyphen --namespace "$NS_ID" + assert_failure + run_otdfctl_obl create --name inval!d.chars --namespace "$NS_ID" + assert_failure + + # missing flag + run_otdfctl_obl create + assert_failure + assert_output --partial "Flag '--name' is required" + + # conflict + run_otdfctl_obl create --name test_create_obl_conflict --namespace "$NS_ID" --json + assert_success + created_id="$(echo "$output" | jq -r '.id')" + run_otdfctl_obl create --name test_create_obl_conflict --namespace "$NS_ID" + assert_failure + assert_output --partial "already_exists" + + # cleanup + run_otdfctl_obl delete --id $created_id --force +} + +@test "Get an obligation - Good" { + # setup an obligation to get + run_otdfctl_obl create --name test_get_obl --namespace "$NS_ID" --json + assert_success + created_id="$(echo "$output" | jq -r '.id')" + + # get by id + run_otdfctl_obl get --id "$created_id" --json + assert_success + [ "$(echo "$output" | jq -r '.id')" = "$created_id" ] + [ "$(echo "$output" | jq -r '.name')" = "test_get_obl" ] + + # get by fqn + run_otdfctl_obl get --fqn "https://${NS_NAME}/obl/test_get_obl" --json + assert_success + [ "$(echo "$output" | jq -r '.id')" = "$created_id" ] + [ "$(echo "$output" | jq -r '.name')" = "test_get_obl" ] + + # cleanup + run_otdfctl_obl delete --id $created_id --force +} + +@test "Get an obligation - Bad" { + run_otdfctl_obl get + assert_failure + assert_output --partial "Error: at least one of the flags in the group" + assert_output --partial "id" + assert_output --partial "fqn" + + run_otdfctl_obl get --id 'not_a_uuid' + assert_failure + assert_output --partial "must be a valid UUID" + + run_otdfctl_obl get --id '08db7417-bd97-4455-b308-7d9e94e43440' --fqn 'https://example.com/obl/example' + assert_failure + assert_output --partial "Error: if any flags in the group" + assert_output --partial "id" + assert_output --partial "fqn" +} + +@test "List obligations" { + # setup obligations to list + run_otdfctl_obl create --name test_list_obl_1 --namespace "$NS_ID" --json + obl1_id="$(echo "$output" | jq -r '.id')" + run_otdfctl_obl create --name test_list_obl_2 --namespace "$NS_ID" --json + obl2_id="$(echo "$output" | jq -r '.id')" + + run_otdfctl_obl list + assert_success + assert_output --partial "$obl1_id" + assert_output --partial "test_list_obl_1" + assert_output --partial "$obl2_id" + assert_output --partial "test_list_obl_2" + assert_output --partial "Total" + assert_line --regexp "Current Offset.*0" + + run_otdfctl_obl list --json + assert_success + assert_not_equal $(echo "$output" | jq -r 'pagination') "null" + total=$(echo "$output" | jq -r '.pagination.total') + [[ "$total" -ge 1 ]] + + # cleanup + run_otdfctl_obl delete --id $obl1_id --force + run_otdfctl_obl delete --id $obl2_id --force +} + +@test "Update obligation" { + # setup an obligation to update + run_otdfctl_obl create --name test_update_obl --namespace "$NS_ID" --json + assert_success + created_id="$(echo "$output" | jq -r '.id')" + + # force replace labels + run_otdfctl_obl update --id "$created_id" -l key=other --force-replace-labels --json + assert_success + [ "$(echo "$output" | jq -r '.id')" = "$created_id" ] + [ "$(echo "$output" | jq -r '.name')" = "test_update_obl" ] + [ "$(echo "$output" | jq -r '.metadata.labels | keys | length')" = "1" ] + [ "$(echo "$output" | jq -r '.metadata.labels.key')" = "other" ] + + # renamed + run_otdfctl_obl update --id "$created_id" --name test_renamed_obl --json + assert_success + [ "$(echo "$output" | jq -r '.id')" = "$created_id" ] + [ "$(echo "$output" | jq -r '.name')" = "test_renamed_obl" ] + [ "$(echo "$output" | jq -r '.name')" != "test_update_obl" ] + + # cleanup + run_otdfctl_obl delete --id $created_id --force +} + +@test "Delete obligation - Good" { + # setup an obligation to delete + run_otdfctl_obl create --name test_delete_obl --namespace "$NS_ID" --json + created_id="$(echo "$output" | jq -r '.id')" + + run_otdfctl_obl delete --id "$created_id" --force + assert_success +} + +@test "Delete obligation - Bad" { + # no id + run_otdfctl_obl delete + assert_failure + assert_output --partial "Error: at least one of the flags in the group" + assert_output --partial "id" + assert_output --partial "fqn" + + # invalid id + run_otdfctl_obl delete --id 'not_a_uuid' + assert_failure + assert_output --partial "must be a valid UUID" + + # id and fqn exclusive + run_otdfctl_obl delete --id '08db7417-bd97-4455-b308-7d9e94e43440' --fqn 'https://example.com/obl/example' + assert_failure + assert_output --partial "Error: if any flags in the group" + assert_output --partial "id" + assert_output --partial "fqn" +} + +# Tests for obligation values + +@test "Create an obligation value - Good" { + # simple by obligation ID + run_otdfctl_obl_values create --obligation "$OBL_ID" --value test_create_obl_val --json + assert_success + [ "$(echo "$output" | jq -r '.value')" = "test_create_obl_val" ] + [ -n "$(echo "$output" | jq -r '.id')" ] + [ -n "$(echo "$output" | jq -r '.created_at')" ] + [ -n "$(echo "$output" | jq -r '.updated_at')" ] + created_id_simple="$(echo "$output" | jq -r '.id')" + + # simple by obligation FQN + run_otdfctl_obl_values create --obligation "https://$NS_NAME/obl/$OBL_NAME" --value test_create_obl_val_by_obl_fqn --json + assert_success + [ "$(echo "$output" | jq -r '.value')" = "test_create_obl_val_by_obl_fqn" ] + [ -n "$(echo "$output" | jq -r '.id')" ] + [ -n "$(echo "$output" | jq -r '.created_at')" ] + [ -n "$(echo "$output" | jq -r '.updated_at')" ] + created_id_simple_by_fqn=$(echo "$output" | jq -r '.id') + # cleanup + run_otdfctl_obl_values delete --id $created_id_simple --force + run_otdfctl_obl_values delete --id $created_id_simple_by_fqn --force +} + +@test "Create an obligation value - Bad" { + # bad obligation value names + run_otdfctl_obl_values create --obligation "$OBL_ID" --value ends_underscored_ + assert_failure + run_otdfctl_obl_values create --obligation "$OBL_ID" --value -first-char-hyphen + assert_failure + run_otdfctl_obl_values create --obligation "$OBL_ID" --value inval!d.chars + assert_failure + + # missing flag + run_otdfctl_obl_values create + assert_failure + assert_output --partial "Flag '--obligation' is required" + run_otdfctl_obl_values create --obligation "$OBL_ID" + assert_failure + assert_output --partial "Flag '--value' is required" + + # non-existent obligation fqn + run_otdfctl_obl_values create --obligation invalid_fqn --value test_create_obl_val + assert_failure + assert_output --partial "obligation_fqn: value must be a valid URI [string.uri]" + + # conflict + run_otdfctl_obl_values create --obligation "$OBL_ID" --value test_create_obl_val_conflict --json + assert_success + created_id="$(echo "$output" | jq -r '.id')" + run_otdfctl_obl_values create --obligation "$OBL_ID" --value test_create_obl_val_conflict + assert_failure + assert_output --partial "already_exists" + + # cleanup + run_otdfctl_obl_values delete --id $created_id --force +} + +@test "Create an obligation value with triggers - JSON Array - Success" { + # test with single trigger (new nested format) + triggers_json='[{"action": "'$ACTION_1_NAME'", "attribute_value": "'$ATTR_VAL_FQN'", "context": {"pep": {"client_id": "test-client"}}}]' + run ./otdfctl $HOST $WITH_CREDS policy obligations values create --obligation "$OBL_ID" --value test_val_single_trigger --triggers "$triggers_json" --json + assert_success + single_trigger_val_id=$(echo "$output" | jq -r '.id') + assert_equal "$(echo "$output" | jq -r '.value')" "test_val_single_trigger" + assert_not_equal "$(echo "$output" | jq -r '.id')" "null" + validate_triggers "$output" "1" "$ATTR_VAL_ID;$ATTR_VAL_FQN;$ACTION_1_ID;$ACTION_1_NAME;test-client" + cleanup_obligation_value "$single_trigger_val_id" + assert_success + + # test with multiple triggers (scoped and unscoped) + triggers_json='[{"action": "'$ACTION_1_NAME'", "attribute_value": "'$ATTR_VAL_FQN'", "context": {"pep": {"client_id": "test-client"}}}, {"action": "'$ACTION_2_NAME'", "attribute_value": "'$ATTR_VAL_FQN'"}]' + run ./otdfctl $HOST $WITH_CREDS policy obligations values create --obligation "$OBL_ID" --value test_val_multiple_triggers --triggers "$triggers_json" --json + assert_success + multiple_trigger_val_id=$(echo "$output" | jq -r '.id') + assert_equal "$(echo "$output" | jq -r '.value')" "test_val_multiple_triggers" + assert_not_equal "$(echo "$output" | jq -r '.id')" "null" + validate_triggers "$output" "2" "$ATTR_VAL_ID;$ATTR_VAL_FQN;$ACTION_1_ID;$ACTION_1_NAME;test-client" "$ATTR_VAL_ID;$ATTR_VAL_FQN;$ACTION_2_ID;$ACTION_2_NAME;" + cleanup_obligation_value "$multiple_trigger_val_id" + assert_success + + # test with unscoped trigger + triggers_json='[{"action": "'$ACTION_1_NAME'", "attribute_value": "'$ATTR_VAL_FQN'"}]' + run ./otdfctl $HOST $WITH_CREDS policy obligations values create --obligation "$OBL_ID" --value test_val_unscoped_trigger --triggers "$triggers_json" --json + assert_success + unscoped_trigger_val_id=$(echo "$output" | jq -r '.id') + assert_equal "$(echo "$output" | jq -r '.value')" "test_val_unscoped_trigger" + assert_not_equal "$(echo "$output" | jq -r '.id')" "null" + validate_triggers "$output" "1" "$ATTR_VAL_ID;$ATTR_VAL_FQN;$ACTION_1_ID;$ACTION_1_NAME;" + cleanup_obligation_value "$unscoped_trigger_val_id" + assert_success +} + +@test "Create an obligation value with triggers - JSON File - Success" { + # create a temporary triggers file + cat > "$SHARED_TRIGGERS_FILE" << EOF +[ + { + "action": "$ACTION_1_NAME", + "attribute_value": "$ATTR_VAL_FQN", + "context": { + "pep": { + "client_id": "file-client-1" + } + } + }, + { + "action": "$ACTION_2_NAME", + "attribute_value": "$ATTR_VAL_FQN" + } +] +EOF + + # test with triggers from file + run_otdfctl_obl_values create --obligation "$OBL_ID" --value test_val_file_triggers --triggers "$SHARED_TRIGGERS_FILE" --json + assert_success + file_trigger_val_id=$(echo "$output" | jq -r '.id') + assert_equal "$(echo "$output" | jq -r '.value')" "test_val_file_triggers" + assert_not_equal "$(echo "$output" | jq -r '.id')" "null" + validate_triggers "$output" "2" "$ATTR_VAL_ID;$ATTR_VAL_FQN;$ACTION_1_ID;$ACTION_1_NAME;file-client-1" "$ATTR_VAL_ID;$ATTR_VAL_FQN;$ACTION_2_ID;$ACTION_2_NAME;" + + # cleanup + cleanup_obligation_value "$file_trigger_val_id" +} + +@test "Create an obligation value with triggers - Bad" { + # test with invalid JSON + run ./otdfctl $HOST $WITH_CREDS policy obligations values create --obligation "$OBL_ID" --value test_val_bad_json --triggers '{"invalid": json}' + assert_failure + assert_output --partial "Invalid trigger configuration" + assert_output --partial "failed to parse trigger JSON" + + # test with missing required fields + run ./otdfctl $HOST $WITH_CREDS policy obligations values create --obligation "$OBL_ID" --value test_val_missing_action --triggers '[{"attribute_value": "https://test.com/attr/test/value/test"}]' + assert_failure + assert_output --partial "Invalid trigger configuration" + assert_output --partial "action is required" + + run ./otdfctl $HOST $WITH_CREDS policy obligations values create --obligation "$OBL_ID" --value test_val_missing_attr --triggers '[{"action": "read"}]' + assert_failure + assert_output --partial "Invalid trigger configuration" + assert_output --partial "attribute_value is required" + + # test with empty required fields + run ./otdfctl $HOST $WITH_CREDS policy obligations values create --obligation "$OBL_ID" --value test_val_empty_action --triggers '[{"action": "", "attribute_value": "https://test.com/attr/test/value/test"}]' + assert_failure + assert_output --partial "Invalid trigger configuration" + assert_output --partial "action is required" + + run ./otdfctl $HOST $WITH_CREDS policy obligations values create --obligation "$OBL_ID" --value test_val_empty_attr --triggers '[{"action": "read", "attribute_value": ""}]' + assert_failure + assert_output --partial "Invalid trigger configuration" + assert_output --partial "attribute_value is required" + + run ./otdfctl $HOST $WITH_CREDS policy obligations values create --obligation "$OBL_ID" --value test_val_empty_attr --triggers '[{"attribute_value": "https://test.com/attr/test/value/test", "action": "read"}, {"action": "write"}]' + assert_failure + assert_output --partial "Invalid trigger configuration" + assert_output --partial "attribute_value is required" + + # test with non-existent file + run ./otdfctl $HOST $WITH_CREDS policy obligations values create --obligation "$OBL_ID" --value test_val_nonexistent_file --triggers "/nonexistent/file.json" + assert_failure + assert_output --partial "Invalid trigger configuration" + assert_output --partial "failed to parse trigger JSON" + + # test with invalid file content + invalid_file="/tmp/invalid_triggers_$$.json" + echo "invalid json content" > "$invalid_file" + run_otdfctl_obl_values create --obligation "$OBL_ID" --value test_val_invalid_file --triggers "$invalid_file" + assert_failure + assert_output --partial "Invalid trigger configuration" + assert_output --partial "failed to parse trigger JSON" + rm -f "$invalid_file" +} + +@test "Get an obligation value - Good" { + # setup an obligation value to get + run_otdfctl_obl_values create --obligation "$OBL_ID" --value test_get_obl_val --json + assert_success + created_id=$(echo "$output" | jq -r '.id') + + # get by id + run_otdfctl_obl_values get --id "$created_id" --json + assert_success + [ "$(echo "$output" | jq -r '.id')" = "$created_id" ] + [ "$(echo "$output" | jq -r '.value')" = "test_get_obl_val" ] + + # get by fqn + run_otdfctl_obl_values get --fqn "https://$NS_NAME/obl/$OBL_NAME/value/test_get_obl_val" --json + assert_success + [ "$(echo "$output" | jq -r '.id')" = "$created_id" ] + [ "$(echo "$output" | jq -r '.value')" = "test_get_obl_val" ] + + # cleanup + run_otdfctl_obl_values delete --id $created_id --force +} + +@test "Get an obligation value - Bad" { + run_otdfctl_obl_values get + assert_failure + assert_output --partial "Error: at least one of the flags in the group" + assert_output --partial "id" + assert_output --partial "fqn" + + # invalid id + run_otdfctl_obl_values get --id 'not_a_uuid' + assert_failure + assert_output --partial "must be a valid UUID" + + # invalid fqn + run_otdfctl_obl_values get --fqn 'not_a_fqn' + assert_failure + assert_output --partial "must be a valid URI" + + # id and fqn exclusive + run_otdfctl_obl_values get --id '08db7417-bd97-4455-b308-7d9e94e43440' --fqn 'https://example.com/obl/example/value/value1' + assert_failure + assert_output --partial "Error: if any flags in the group" + assert_output --partial "id" + assert_output --partial "fqn" +} + +@test "Update obligation values" { + # setup an obligation value to update + run_otdfctl_obl_values create --obligation "$OBL_ID" --value test_update_obl_val --json + assert_success + created_id="$(echo "$output" | jq -r '.id')" + + # force replace labels + run_otdfctl_obl_values update --id "$created_id" -l key=other --force-replace-labels --json + assert_success + # Check that metadata.labels has exactly one key + [ "$(echo "$output" | jq -r '.metadata.labels | keys | length')" = "1" ] + # Check that the key "key" exists and has value "other" + [ "$(echo "$output" | jq -r '.metadata.labels.key')" = "other" ] + + # renamed + run_otdfctl_obl_values update --id "$created_id" --value test_renamed_obl_val --json + assert_success + [ "$(echo "$output" | jq -r '.id')" = "$created_id" ] + [ "$(echo "$output" | jq -r '.value')" = "test_renamed_obl_val" ] + [ "$(echo "$output" | jq -r '.value')" != "test_update_obl_val" ] + + # cleanup + run_otdfctl_obl_values delete --id $created_id --force +} + +@test "Update obligation values with triggers - Success" { + # create an obligation value to update + run_otdfctl_obl_values create --obligation "$OBL_ID" --value test_update_with_triggers --json + assert_success + created_id="$(echo "$output" | jq -r '.id')" + + # verify obligation value has no triggers initially + run_otdfctl_obl_values get --id "$created_id" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.triggers | length')" "0" + + # update with triggers (new nested format) + triggers_json='[{"action": "'$ACTION_1_NAME'", "attribute_value": "'$ATTR_2_VAL_FQN'", "context": {"pep": {"client_id": "update-client"}}}]' + run ./otdfctl $HOST $WITH_CREDS policy obligations values update --id "$created_id" --value test_updated_with_triggers --triggers "$triggers_json" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.id')" "$created_id" + assert_equal "$(echo "$output" | jq -r '.value')" "test_updated_with_triggers" + validate_triggers "$output" "1" "$ATTR_2_VAL_ID;$ATTR_2_VAL_FQN;$ACTION_1_ID;$ACTION_1_NAME;update-client" + + run_otdfctl_obl_values get --id "$created_id" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.triggers | length')" "1" + + # update with triggers from file + cat > "$SHARED_TRIGGERS_FILE" << EOF +[ + { + "action": "$ACTION_2_NAME", + "attribute_value": "$ATTR_VAL_FQN" + }, + { + "action": "$ACTION_1_NAME", + "attribute_value": "$ATTR_VAL_FQN" + } +] +EOF + + run_otdfctl_obl_values update --id "$created_id" --value test_updated_from_file --triggers "$SHARED_TRIGGERS_FILE" --json + assert_success + validate_triggers "$output" "2" "$ATTR_VAL_ID;$ATTR_VAL_FQN;$ACTION_2_ID;$ACTION_2_NAME;" "$ATTR_VAL_ID;$ATTR_VAL_FQN;$ACTION_1_ID;$ACTION_1_NAME;" + + run_otdfctl_obl_values get --id "$created_id" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.triggers | length')" "2" + + # cleanup + cleanup_obligation_value "$created_id" +} + +@test "Update obligation values with triggers - Bad" { + # create an obligation value to update + run_otdfctl_obl_values create --obligation "$OBL_ID" --value test_update_bad_triggers --json + assert_success + created_id="$(echo "$output" | jq -r '.id')" + + # test with invalid JSON + run ./otdfctl $HOST $WITH_CREDS policy obligations values update --id "$created_id" --triggers '{"invalid": json}' + assert_failure + assert_output --partial "Invalid trigger configuration" + assert_output --partial "failed to parse trigger JSON" + + # test with missing required fields + run ./otdfctl $HOST $WITH_CREDS policy obligations values update --id "$created_id" --triggers '[{"attribute_value": "https://test.com/attr/test/value/test"}]' + assert_failure + assert_output --partial "Invalid trigger configuration" + assert_output --partial "action is required" + + # Missing required fields many + run ./otdfctl $HOST $WITH_CREDS policy obligations values update --id "$created_id" --triggers '[{"attribute_value": "https://test.com/attr/test/value/test", "action": "read"}, {"action": "write"}]' + assert_failure + assert_output --partial "Invalid trigger configuration" + assert_output --partial "attribute_value is required" + + # cleanup + cleanup_obligation_value "$created_id" +} + +@test "Delete obligation value - Good" { + # setup a value to delete + run_otdfctl_obl_values create --obligation "$OBL_ID" --value test_delete_obl_val --json + created_id="$(echo "$output" | jq -r '.id')" + + run_otdfctl_obl_values delete --id "$created_id" --force + assert_success +} + +@test "Delete obligation value - Bad" { + # no id + run_otdfctl_obl_values delete + assert_failure + assert_output --partial "Error: at least one of the flags in the group" + assert_output --partial "id" + assert_output --partial "fqn" + + # invalid id + run_otdfctl_obl_values delete --id 'not_a_uuid' + assert_failure + assert_output --partial "must be a valid UUID" + + # id and fqn exclusive + run_otdfctl_obl_values delete --id '08db7417-bd97-4455-b308-7d9e94e43440' --fqn 'https://example.com/obl/example/value/value1' + assert_failure + assert_output --partial "Error: if any flags in the group" + assert_output --partial "id" + assert_output --partial "fqn" +} + +# Tests for obligation triggers + +@test "Create an obligation trigger - Required Only - IDs - Success" { + # setup an obligation value to use + run_otdfctl_obl_values create --obligation "$OBL_ID" --value "test_obl_val_for_trigger" --json + obl_val_id=$(echo "$output" | jq -r '.id') + + # create trigger + run_otdfctl_obl_triggers create --attribute-value "$ATTR_VAL_ID" --action "$ACTION_1_ID" --obligation-value "$obl_val_id" --json + assert_success + [ "$(echo "$output" | jq -r '.id')" != "null" ] + trigger_id=$(echo "$output" | jq -r '.id') + assert_equal "$(echo "$output" | jq -r '.attribute_value.id')" "$ATTR_VAL_ID" + assert_equal "$(echo "$output" | jq -r '.attribute_value.fqn')" "$ATTR_VAL_FQN" + assert_equal "$(echo "$output" | jq -r '.action.id')" "$ACTION_1_ID" + assert_equal "$(echo "$output" | jq -r '.action.name')" "$ACTION_1_NAME" + assert_equal "$(echo "$output" | jq -r '.obligation_value.id')" "$obl_val_id" + assert_equal "$(echo "$output" | jq -r '.obligation_value.value')" "test_obl_val_for_trigger" + assert_equal "$(echo "$output" | jq -r '.obligation_value.obligation.id')" "$OBL_ID" + assert_equal "$(echo "$output" | jq -r '.obligation_value.obligation.namespace.fqn')" "https://$NS_NAME" + assert_equal "$(echo "$output" | jq -r '.obligation_value.fqn')" "https://$NS_NAME/obl/$OBL_NAME/value/test_obl_val_for_trigger" + + # cleanup + cleanup_trigger "$trigger_id" + cleanup_obligation_value "$obl_val_id" +} + +@test "Create an obligation trigger - Required Only - FQNs - Success" { + # setup an obligation value to use + run_otdfctl_obl_values create --obligation "$OBL_ID" --value "test_obl_val_for_trigger" --json + obl_val_id=$(echo "$output" | jq -r '.id') + obl_val_fqn="https://$NS_NAME/obl/$OBL_NAME/value/test_obl_val_for_trigger" + + # create trigger + run_otdfctl_obl_triggers create --attribute-value "$ATTR_VAL_FQN" --action "$ACTION_1_NAME" --obligation-value "$obl_val_fqn" --json + assert_success + [ "$(echo "$output" | jq -r '.id')" != "null" ] + trigger_id=$(echo "$output" | jq -r '.id') + assert_equal "$(echo "$output" | jq -r '.attribute_value.id')" "$ATTR_VAL_ID" + assert_equal "$(echo "$output" | jq -r '.attribute_value.fqn')" "$ATTR_VAL_FQN" + assert_equal "$(echo "$output" | jq -r '.action.id')" "$ACTION_1_ID" + assert_equal "$(echo "$output" | jq -r '.action.name')" "$ACTION_1_NAME" + assert_equal "$(echo "$output" | jq -r '.obligation_value.id')" "$obl_val_id" + assert_equal "$(echo "$output" | jq -r '.obligation_value.value')" "test_obl_val_for_trigger" + assert_equal "$(echo "$output" | jq -r '.obligation_value.obligation.id')" "$OBL_ID" + assert_equal "$(echo "$output" | jq -r '.obligation_value.obligation.namespace.fqn')" "https://$NS_NAME" + assert_equal "$(echo "$output" | jq -r '.obligation_value.fqn')" "https://$NS_NAME/obl/$OBL_NAME/value/test_obl_val_for_trigger" + assert_equal "$(echo "$output" | jq -r '.metadata.labels')" "null" + assert_equal "$(echo "$output" | jq -r '.context.pep')" "null" + + # cleanup + cleanup_trigger "$trigger_id" + cleanup_obligation_value "$obl_val_id" +} + +@test "Create an obligation trigger - Optional Fields - Success" { + # setup an obligation value to use + run_otdfctl_obl_values create --obligation "$OBL_ID" --value "test_obl_val_for_trigger" --json + obl_val_id=$(echo "$output" | jq -r '.id') + + # create trigger + client_id="a-pep" + run_otdfctl_obl_triggers create --attribute-value "$ATTR_VAL_ID" --action "$ACTION_2_ID" --obligation-value "$obl_val_id" --client-id "$client_id" --label "my=label" --json + assert_success + assert_not_equal "$(echo "$output" | jq -r '.id')" "null" + trigger_id=$(echo "$output" | jq -r '.id') + assert_equal "$(echo "$output" | jq -r '.attribute_value.id')" "$ATTR_VAL_ID" + assert_equal "$(echo "$output" | jq -r '.attribute_value.fqn')" "$ATTR_VAL_FQN" + assert_equal "$(echo "$output" | jq -r '.action.id')" "$ACTION_2_ID" + assert_equal "$(echo "$output" | jq -r '.action.name')" "$ACTION_2_NAME" + assert_equal "$(echo "$output" | jq -r '.obligation_value.id')" "$obl_val_id" + assert_equal "$(echo "$output" | jq -r '.obligation_value.value')" "test_obl_val_for_trigger" + assert_equal "$(echo "$output" | jq -r '.obligation_value.obligation.id')" "$OBL_ID" + assert_equal "$(echo "$output" | jq -r '.obligation_value.obligation.namespace.fqn')" "https://$NS_NAME" + assert_equal "$(echo "$output" | jq -r '.metadata.labels.my')" "label" + assert_equal "$(echo "$output" | jq -r '.context | length')" "1" + assert_equal "$(echo "$output" | jq -r '.context[0].pep.client_id')" "$client_id" + assert_equal "$(echo "$output" | jq -r '.obligation_value.fqn')" "https://$NS_NAME/obl/$OBL_NAME/value/test_obl_val_for_trigger" + + # cleanup + cleanup_trigger "$trigger_id" + cleanup_obligation_value "$obl_val_id" +} + +@test "Create an obligation trigger - Same tuple different client IDs - Success" { + # setup an obligation value to use + run_otdfctl_obl_values create --obligation "$OBL_ID" --value "test_obl_val_for_multi_peps" --json + assert_success + obl_val_id=$(echo "$output" | jq -r '.id') + + # create first client-scoped trigger + client_id_1="a-pep" + run_otdfctl_obl_triggers create --attribute-value "$ATTR_VAL_ID" --action "$ACTION_2_ID" --obligation-value "$obl_val_id" --client-id "$client_id_1" --json + assert_success + trigger_id_1=$(echo "$output" | jq -r '.id') + assert_not_equal "$trigger_id_1" "null" + assert_equal "$(echo "$output" | jq -r '.context[0].pep.client_id')" "$client_id_1" + + # create second client-scoped trigger with same tuple but different client id + client_id_2="b-pep" + run_otdfctl_obl_triggers create --attribute-value "$ATTR_VAL_ID" --action "$ACTION_2_ID" --obligation-value "$obl_val_id" --client-id "$client_id_2" --json + assert_success + trigger_id_2=$(echo "$output" | jq -r '.id') + assert_not_equal "$trigger_id_2" "null" + assert_not_equal "$trigger_id_1" "$trigger_id_2" + assert_equal "$(echo "$output" | jq -r '.context[0].pep.client_id')" "$client_id_2" + + # cleanup + cleanup_trigger "$trigger_id_1" + cleanup_trigger "$trigger_id_2" + cleanup_obligation_value "$obl_val_id" +} + +@test "Create an obligation trigger - Bad" { + # missing flags + run_otdfctl_obl_triggers create --attribute-value "http://example.com/attr/attr_name/value/attr_value" --action "read" + assert_failure + assert_output --partial "Flag '--obligation-value' is required" + + run_otdfctl_obl_triggers create --obligation-value "http://example.com/attr/attr_name/value/attr_value" --action "read" + assert_failure + assert_output --partial "Flag '--attribute-value' is required" + + run_otdfctl_obl_triggers create --obligation-value "http://example.com/attr/attr_name/value/attr_value" --attribute-value "http://example.com/attr/attr_name/value/attr_value" + assert_failure + assert_output --partial "Flag '--action' is required" +} + +@test "Delete an obligation trigger - Good" { + # setup an obligation value to use + run_otdfctl_obl_values create --obligation "$OBL_ID" --value "test_obl_val_for_del_trigger" --json + assert_success + obl_val_id=$(echo "$output" | jq -r '.id') + + # create trigger + run_otdfctl_obl_triggers create --attribute-value "$ATTR_2_VAL_ID" --action "$ACTION_2_ID" --obligation-value "$obl_val_id" --json + assert_success + assert_not_equal "$(echo "$output" | jq -r '.id')" "null" + trigger_id=$(echo "$output" | jq -r '.id') + + # delete trigger + run_otdfctl_obl_triggers delete --id "$trigger_id" --force --json + assert_success + assert_equal "$(echo "$output" | jq -r '.id')" "$trigger_id" + + # cleanup + cleanup_obligation_value "$obl_val_id" +} + +@test "List obligation triggers - No filters" { + setup_triggers_test_data + + run_otdfctl_obl_triggers list --json + assert_success + + # Verify all our triggers are present + actual_triggers=$(echo "$output" | jq -r '.triggers | length') + assert [ "$actual_triggers" -ge 2 ] + validate_triggers "$output" "2" "$LIST_ATTR_2_VAL_1_ID;$LIST_ATTR_2_VAL_1_FQN;$LIST_ACTION_2_ID;$LIST_ACTION_2_NAME;$CLIENT_ID_LIST;$LIST_OBL_2_VAL_ID;$LIST_OBL_VAL_2_FQN" "$LIST_ATTR_1_VAL_1_ID;$LIST_ATTR_1_VAL_1_FQN;$LIST_ACTION_1_ID;$LIST_ACTION_1_NAME;$CLIENT_ID_LIST;$LIST_OBL_1_VAL_ID;$LIST_OBL_VAL_1_FQN" + validate_pagination "$output" "null" "2" "null" +} + +@test "List obligation triggers - Limit and Offset" { + setup_triggers_test_data + run_otdfctl_obl_triggers list --limit 1 --offset 0 --json + assert_success + assert_equal "$(echo "$output" | jq -r '.triggers | length')" "1" + validate_triggers "$output" "1" "$LIST_ATTR_2_VAL_1_ID;$LIST_ATTR_2_VAL_1_FQN;$LIST_ACTION_2_ID;$LIST_ACTION_2_NAME;$CLIENT_ID_LIST;$LIST_OBL_2_VAL_ID;$LIST_OBL_VAL_2_FQN" + validate_pagination "$output" "null" "2" "1" + + run_otdfctl_obl_triggers list --limit 1 --offset 1 --json + assert_success + assert_equal "$(echo "$output" | jq -r '.triggers | length')" "1" + validate_triggers "$output" "1" "$LIST_ATTR_1_VAL_1_ID;$LIST_ATTR_1_VAL_1_FQN;$LIST_ACTION_1_ID;$LIST_ACTION_1_NAME;$CLIENT_ID_LIST;$LIST_OBL_1_VAL_ID;$LIST_OBL_VAL_1_FQN" + validate_pagination "$output" "1" "2" "null" +} + +@test "List obligation triggers - Filter by Namespace ID" { + setup_triggers_test_data + run_otdfctl_obl_triggers list --namespace "$LIST_NS_1_ID" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.triggers | length')" "1" + validate_triggers "$output" "1" "$LIST_ATTR_1_VAL_1_ID;$LIST_ATTR_1_VAL_1_FQN;$LIST_ACTION_1_ID;$LIST_ACTION_1_NAME;$CLIENT_ID_LIST;$LIST_OBL_1_VAL_ID;$LIST_OBL_VAL_1_FQN" + validate_pagination "$output" "null" "1" "null" +} + +@test "List obligation triggers - Filter by Namespace FQN" { + setup_triggers_test_data + run_otdfctl_obl_triggers list --namespace "https://$LIST_NS_2_NAME" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.triggers | length')" "1" + validate_triggers "$output" "1" "$LIST_ATTR_2_VAL_1_ID;$LIST_ATTR_2_VAL_1_FQN;$LIST_ACTION_2_ID;$LIST_ACTION_2_NAME;$CLIENT_ID_LIST;$LIST_OBL_2_VAL_ID;$LIST_OBL_VAL_2_FQN" + validate_pagination "$output" "null" "1" "null" +} diff --git a/otdfctl/e2e/otdfctl-utils.sh b/otdfctl/e2e/otdfctl-utils.sh new file mode 100644 index 0000000000..3040454106 --- /dev/null +++ b/otdfctl/e2e/otdfctl-utils.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + + +run_otdfctl_key() { + run sh -c "./otdfctl policy kas-registry key $HOST $WITH_CREDS $*" +} + +delete_all_keys_in_kas() { + local kas_id="$1" + echo "Attempting to delete all keys in KAS registry: $kas_id" + + # List all keys in the specified KAS registry + run_otdfctl_key list --kas "$kas_id" --json + assert_success + + local key_ids=() + local keys_to_delete=$(echo "$output" | jq -c '.kas_keys[] | {id: .key.id, key_id: .key.key_id, kas_uri: .kas_uri}') + + if [ -z "$keys_to_delete" ]; then + echo "No keys found to delete in KAS registry: $kas_id" + return 0 + fi + + echo "Found $(echo "$keys_to_delete" | wc -l | xargs) keys to delete in KAS registry: $kas_id" + echo "$keys_to_delete" | while read -r key_info; do + local key_system_id=$(echo "$key_info" | jq -r '.id') + local kid=$(echo "$key_info" | jq -r '.key_id') + local key_kas_uri=$(echo "$key_info" | jq -r '.kas_uri') + + echo "Deleting key: $key_user_id (system ID: $key_system_id) from KAS URI: $key_kas_uri" + run_otdfctl_key unsafe delete --id "$key_system_id" --kas-uri "$key_kas_uri" --key-id "$kid" --force + assert_success + if [ "$status" -ne 0 ]; then + echo "Warning: Failed to delete key $key_system_id. Error: $output" >&2 + else + echo "Successfully deleted key: $key_system_id" + fi + done +} + + +delete_kas_registry() { + local kas_id="$1" + run sh -c "./otdfctl $HOST $WITH_CREDS policy kas-registry delete --id "$kas_id" --force" + assert_success +} + +delete_provider_config() { + local pc_id="$1" + run sh -c "./otdfctl $HOST $WITH_CREDS policy keymanagement provider delete --id "$pc_id" --force" + assert_success +} diff --git a/otdfctl/e2e/profile.bats b/otdfctl/e2e/profile.bats new file mode 100755 index 0000000000..aa5f553912 --- /dev/null +++ b/otdfctl/e2e/profile.bats @@ -0,0 +1,279 @@ +#!/usr/bin/env bats + +setup_file() { + # Prefix for all profiles created in this file to avoid clashing + PROFILE_TEST_PREFIX="bats-profile-$(date +%s)" + export PROFILE_TEST_PREFIX +} + +setup() { + load "${BATS_LIB_PATH}/bats-support/load.bash" + load "${BATS_LIB_PATH}/bats-assert/load.bash" + + run_otdfctl() { + run bash -c "./otdfctl profile $*" + } + + run_otdfctl_profile_keyring() { + # LEGACY_OTDFCTL_BIN gets set in the action.yaml + # It is v0.26.2 of otdfctl + run bash -c "$LEGACY_OTDFCTL_BIN profile $*" + } +} + +teardown() { + run_otdfctl profile delete-all --force + run_otdfctl profile delete-all --store keyring --force +} + +@test "profile create" { + profile="${PROFILE_TEST_PREFIX}-create" + run_otdfctl create "$profile" http://localhost:8080 + assert_success + assert_output --partial "Profile ${profile} created" + + # Invalid endpoint should fail with a helpful message + run_otdfctl create "$profile" localhost:8080 + assert_failure + assert_output --partial "Failed to create profile" + assert_output --partial "invalid scheme" +} + +@test "profile list shows profiles and default" { + profile1="${PROFILE_TEST_PREFIX}-list-1" + profile2="${PROFILE_TEST_PREFIX}-list-2" + + run_otdfctl create "$profile1" http://localhost:8080 + assert_success + + run_otdfctl create "$profile2" http://localhost:8080 --set-default + assert_success + + run_otdfctl list + assert_success + assert_output --partial "Listing profiles from filesystem" + assert_output --partial " ${profile1}" + assert_output --partial "* ${profile2}" + + profile1_keyring="${PROFILE_TEST_PREFIX}-list-keyring-1" + profile2_keyring="${PROFILE_TEST_PREFIX}-list-keyring-2" + + run_otdfctl_profile_keyring create "$profile1_keyring" http://localhost:8080 + assert_success + + run_otdfctl_profile_keyring create --set-default "$profile2_keyring" http://localhost:8080 + assert_success + + run_otdfctl list --store keyring + assert_success + assert_output --partial "Listing profiles from keyring" + assert_output --partial " ${profile1_keyring}" + assert_output --partial "* ${profile2_keyring}" +} + +@test "profile get shows profile details" { + profile="${PROFILE_TEST_PREFIX}-get" + + run_otdfctl create "$profile" http://localhost:8080 --set-default + assert_success + + run_otdfctl get "$profile" + assert_success + assert_output --partial "Profile" + assert_output --partial "$profile" + assert_output --partial "Endpoint" + assert_output --partial "http://localhost:8080" + assert_output --partial "Is default" + assert_output --partial "true" + + profile_keyring="${PROFILE_TEST_PREFIX}-get-keyring" + + run_otdfctl_profile_keyring create --set-default "$profile_keyring" http://localhost:8080 + assert_success + + run_otdfctl get "$profile_keyring" --store keyring + assert_success + assert_output --partial "Profile" + assert_output --partial "$profile_keyring" + assert_output --partial "Endpoint" + assert_output --partial "http://localhost:8080" + assert_output --partial "Is default" + assert_output --partial "true" +} + +@test "profile delete removes profile" { + base="${PROFILE_TEST_PREFIX}-delete" + default_profile="${base}-default" + target_profile="${base}-target" + + run_otdfctl create "$default_profile" http://localhost:8080 --set-default + assert_success + + run_otdfctl create "$target_profile" http://localhost:8080 + assert_success + + run_otdfctl delete "$target_profile" + assert_success + assert_output --partial "Deleted profile ${target_profile} from filesystem" + + run_otdfctl profile list + assert_success + refute_output --partial "$target_profile" + + base_keyring="${PROFILE_TEST_PREFIX}-delete-keyring" + default_profile_keyring="${base_keyring}-default" + target_profile_keyring="${base_keyring}-target" + + run_otdfctl_profile_keyring create --set-default "$default_profile_keyring" http://localhost:8080 + assert_success + + run_otdfctl_profile_keyring create "$target_profile_keyring" http://localhost:8080 + assert_success + + run_otdfctl delete "$target_profile_keyring" --store keyring + assert_success + assert_output --partial "Deleted profile ${target_profile_keyring} from keyring" + + run_otdfctl list --store keyring + assert_success + refute_output --partial "$target_profile_keyring" +} + +@test "profile set-default updates default profile" { + base="${PROFILE_TEST_PREFIX}-set-default" + profile1="${base}-1" + profile2="${base}-2" + + run_otdfctl create "$profile1" http://localhost:8080 --set-default + assert_success + + run_otdfctl create "$profile2" http://localhost:8081 + assert_success + + run_otdfctl set-default "$profile2" + assert_success + assert_output --partial "Set profile ${profile2} as default" + + run_otdfctl list + assert_success + assert_output --partial "* ${profile2}" +} + +@test "profile set-endpoint updates endpoint" { + profile="${PROFILE_TEST_PREFIX}-set-endpoint" + + run_otdfctl create "$profile" http://localhost:8080 + assert_success + + run_otdfctl set-endpoint "$profile" http://localhost:8081 + assert_success + assert_output --partial "Set endpoint http://localhost:8081 for profile ${profile}" + + run_otdfctl get "$profile" + assert_success + assert_output --partial "http://localhost:8081" +} + +@test "profile delete-all deletes all profiles" { + base="${PROFILE_TEST_PREFIX}-delete-all" + profile1="${base}-1" + profile2="${base}-2" + + run_otdfctl create "$profile1" http://localhost:8080 --set-default + assert_success + + run_otdfctl create "$profile2" http://localhost:8081 + assert_success + + run_otdfctl delete-all --force + assert_success + assert_output --regexp '^Deleted [0-9]+ profiles from filesystem$' + + run_otdfctl list + assert_success + refute_output --partial "$profile1" + refute_output --partial "$profile2" + + base_keyring="${PROFILE_TEST_PREFIX}-delete-all-keyring" + profile1_keyring="${base_keyring}-1" + profile2_keyring="${base_keyring}-2" + + run_otdfctl_profile_keyring create --set-default "$profile1_keyring" http://localhost:8080 + assert_success + + run_otdfctl_profile_keyring create "$profile2_keyring" http://localhost:8081 + assert_success + + run_otdfctl delete-all --store keyring --force + assert_success + assert_output --regexp '^Deleted [0-9]+ profiles from keyring$' + + run_otdfctl list --store keyring + assert_success + refute_output --partial "$profile1_keyring" + refute_output --partial "$profile2_keyring" +} + +@test "profile migrate moves keyring profiles to filesystem" { + base="${PROFILE_TEST_PREFIX}-migrate" + profile1="${base}-1" + profile2="${base}-2" + + run_otdfctl_profile_keyring create --set-default "$profile1" http://localhost:8080 + assert_success + + run_otdfctl_profile_keyring create "$profile2" http://localhost:8081 + assert_success + + run_otdfctl list --store keyring + assert_success + assert_output --partial "$profile1" + assert_output --partial "$profile2" + + run_otdfctl list + assert_success + refute_output --partial "$profile1" + refute_output --partial "$profile2" + + run_otdfctl migrate + assert_success + assert_output --partial "Migration complete." + + run_otdfctl list + assert_success + assert_output --partial "Listing profiles from filesystem" + assert_output --partial "* ${profile1}" + assert_output --partial " ${profile2}" + + run_otdfctl list --store keyring + assert_success + assert_output --partial "Listing profiles from keyring" + refute_output --partial "$profile1" + refute_output --partial "$profile2" +} + +@test "profile keyring cleanup removes all keyring profiles" { + base="${PROFILE_TEST_PREFIX}-cleanup" + profile1="${base}-1" + profile2="${base}-2" + + run_otdfctl_profile_keyring create "$profile1" http://localhost:8080 + assert_success + + run_otdfctl_profile_keyring create "$profile2" http://localhost:8081 + assert_success + + run_otdfctl list --store keyring + assert_success + assert_output --partial "$profile1" + assert_output --partial "$profile2" + + run_otdfctl cleanup --force + assert_success + assert_output --partial "Keyring profile store cleanup complete" + + run_otdfctl list --store keyring + assert_success + refute_output --partial "$profile1" + refute_output --partial "$profile2" +} diff --git a/otdfctl/e2e/provider-config.bats b/otdfctl/e2e/provider-config.bats new file mode 100755 index 0000000000..57cab54d61 --- /dev/null +++ b/otdfctl/e2e/provider-config.bats @@ -0,0 +1,181 @@ +#!/usr/bin/env bats + +setup_file() { + export CREDSFILE=creds.json + echo -n '{"clientId":"opentdf","clientSecret":"secret"}' > $CREDSFILE + export WITH_CREDS="--with-client-creds-file $CREDSFILE" + export HOST='--host http://localhost:8080' + export DEBUG_LEVEL="--log-level debug" + export VALID_CONFIG='{"cached":"key"}' + export BASE64_CONFIG="eyJjYWNoZWQiOiAia2V5In0=" +} + +setup() { + if [ "$RUN_EXPERIMENTAL_TESTS" != "true" ]; then + skip "Skipping experimental test" + fi + load "${BATS_LIB_PATH}/bats-support/load.bash" + load "${BATS_LIB_PATH}/bats-assert/load.bash" + + # invoke binary with credentials + run_otdfctl_key_pc () { + run sh -c "./otdfctl policy keymanagement provider $HOST $WITH_CREDS $*" + } +} + +delete_pc_by_id() { + run_otdfctl_key_pc delete --id "$1" --force + assert_success +} + +######### +# Create Provider Configuration +######### +@test "fail to create provider configuration without config" { + run_otdfctl_key_pc create --name test-value + assert_failure + assert_output --partial "Flag '--config' is required" +} + +@test "fail to create provider configuration without name" { + run_otdfctl_key_pc create --config '{}' + assert_failure + assert_output --partial "Flag '--name' is required" +} + +@test "fail to create provider configuration with invalid config" { + run_otdfctl_key_pc create --name test-config --config test-value + assert_failure + assert_output --partial "invalid_argument" +} + +@test "create provider configuration" { + CONFIG_NAME="test-config" + run_otdfctl_key_pc create --name "$CONFIG_NAME" --config '"$VALID_CONFIG"' --json + assert_success + assert_equal "$(echo "$output" | jq -r .name)" "$CONFIG_NAME" + assert_equal "$(echo "$output" | jq -r .config_json)" "$BASE64_CONFIG" + delete_pc_by_id "$(echo "$output" | jq -r .id)" +} + +@test "get provider configuration by id" { + CONFIG_NAME="test-config-2" + run_otdfctl_key_pc create --name "$CONFIG_NAME" --config '"$VALID_CONFIG"' --json + assert_success + ID=$(echo "$output" | jq -r '.id') + run_otdfctl_key_pc get --id "$ID" --json + assert_success + assert_equal "$(echo "$output" | jq -r .name)" "$CONFIG_NAME" + assert_equal "$(echo "$output" | jq -r .config_json)" "$BASE64_CONFIG" + delete_pc_by_id "$(echo "$output" | jq -r .id)" + } + + +@test "get provider configuration by name" { + CONFIG_NAME="test-config-3" + run_otdfctl_key_pc create --name "$CONFIG_NAME" --config '"$VALID_CONFIG"' --json + assert_success + NAME=$(echo "$output" | jq -r '.name') + run_otdfctl_key_pc get --name "$NAME" --json + assert_success + assert_equal "$(echo "$output" | jq -r .name)" "$CONFIG_NAME" + assert_equal "$(echo "$output" | jq -r .config_json)" "$BASE64_CONFIG" + delete_pc_by_id "$(echo "$output" | jq -r .id)" +} + +@test "fail to get provider configuration - no required flags" { + run_otdfctl_key_pc get + assert_failure +} + +@test "fail to get provider configuration with non-existent name" { + run_otdfctl_key_pc get --name non-existent-config + assert_failure + assert_output --partial "Failed to get provider config: not_found" +} +@test "list provider configurations" { + NAME="tst-config-4" + run_otdfctl_key_pc create --name "$NAME" --config '"$VALID_CONFIG"' --manager "fake-manager" --json + assert_success + ID=$(echo "$output" | jq -r '.id') + run_otdfctl_key_pc list --json + assert_success + assert_equal "$(echo "$output" | jq '.provider_configs | length')" "1" + assert_equal "$(echo "$output" | jq '.pagination.total')" "1" + run_otdfctl_key_pc list + assert_output --partial "Total" + assert_line --regexp "Current Offset.*0" + delete_pc_by_id "$ID" +} + +@test "update provider configuration - success" { + NAME="test-config-5" + UPDATED_NAME="test-config-5-updated" + UPDATED_CONFIG='{"cached": "key-updated"}' + BASE64_UPDATED_CONFIG='eyJjYWNoZWQiOiAia2V5LXVwZGF0ZWQifQ==' + run_otdfctl_key_pc create --name "$NAME" --config '"$VALID_CONFIG"' --json + assert_success + ID=$(echo "$output" | jq -r '.id') + run_otdfctl_key_pc update --id "$ID" --name "$UPDATED_NAME" --config "'$UPDATED_CONFIG'" --json + assert_success + assert_equal "$(echo "$output" | jq -r .id)" "$ID" + assert_equal "$(echo "$output" | jq -r .name)" "$UPDATED_NAME" + assert_equal "$(echo "$output" | jq -r .config_json)" "$BASE64_UPDATED_CONFIG" + delete_pc_by_id "$ID" +} + +@test "fail to update provider configuration - missing id" { + run_otdfctl_key_pc update --name test-config + assert_failure + assert_output --partial "Flag '--id' is required" +} + +@test "fail to update provider configuration - no optional flags" { + NAME="test-config-6" + run_otdfctl_key_pc create --name "$NAME" --config '"$VALID_CONFIG"' --json + ID=$(echo "$output" | jq -r '.id') + run_otdfctl_key_pc update --id "$ID" + assert_failure + assert_output --partial "At least one field (name, config, or metadata labels) must be updated" + delete_pc_by_id "$ID" +} + +@test "fail to update provider configuration - invalid config format" { + NAME="test-config-7" + run_otdfctl_key_pc create --name "$NAME" --config '"$VALID_CONFIG"' --json + assert_success + ID=$(echo "$output" | jq -r '.id') + run_otdfctl_key_pc update --id "$ID" --config "{invalid: json}" + assert_failure + assert_output --partial "invalid_argument" + delete_pc_by_id "$ID" +} + +@test "delete provider configuration -- success" { + NAME="test-config-8" + run_otdfctl_key_pc create --name "$NAME" --config '"$VALID_CONFIG"' --json + ID=$(echo "$output" | jq -r '.id') + run_otdfctl_key_pc delete --id "$ID" --force + assert_success +} + +@test "delete provider configuration fail -- no id" { + run_otdfctl_key_pc delete + assert_failure + assert_output --partial "Flag '--id' is required" +} + +@test "delete provider configuration fail -- no force" { + NAME="test-config-9" + run_otdfctl_key_pc create --name "$NAME" --config '"$VALID_CONFIG"' --json + ID=$(echo "$output" | jq -r '.id') + run_otdfctl_key_pc delete --id "$ID" + assert_failure + assert_output --partial "The '--force' flag is required for this operation" + delete_pc_by_id "$ID" +} + +teardown_file() { + # clear out all test env vars + unset HOST WITH_CREDS DEBUG_LEVEL VALID_CONFIG BASE64_CONFIG +} \ No newline at end of file diff --git a/otdfctl/e2e/registered-resources.bats b/otdfctl/e2e/registered-resources.bats new file mode 100644 index 0000000000..4a39609f58 --- /dev/null +++ b/otdfctl/e2e/registered-resources.bats @@ -0,0 +1,473 @@ +#!/usr/bin/env bats + +# Tests for registered resources + +setup_file() { + export WITH_CREDS='--with-client-creds-file ./creds.json' + export HOST='--host http://localhost:8080' + + # create namespace first (needed for registered resource creation) + export NS_NAME="test-rr.org" + NS_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes namespaces create --name "$NS_NAME" --json | jq -r '.id') + export NS_ID + + # create registered resource used in registered resource values tests + export RR_NAME="test_rr_for_values" + RR_ID=$(./otdfctl $HOST $WITH_CREDS policy registered-resources create --name "$RR_NAME" --namespace "$NS_ID" --json | jq -r '.id') + export RR_ID + + # create custom action to be used in registered resource values tests + export CUSTOM_ACTION_NAME="test_action_for_values" + CUSTOM_ACTION_ID=$(./otdfctl $HOST $WITH_CREDS policy actions create --name "$CUSTOM_ACTION_NAME" --namespace "$NS_ID" --json | jq -r '.id') + export CUSTOM_ACTION_ID + + # get standard read action id in the same namespace used for RR tests + export READ_ACTION_NAME="read" + READ_ACTION_ID=$(./otdfctl $HOST $WITH_CREDS policy actions get --name "$READ_ACTION_NAME" --namespace "$NS_ID" --json | jq -r '.id') + export READ_ACTION_ID + export ATTR_NAME=test_rr_attr + attr_id=$(./otdfctl $HOST $WITH_CREDS policy attributes create --namespace "$NS_ID" --name "$ATTR_NAME" --rule ANY_OF -l key=value --json | jq -r '.id') + ATTR_VAL_1_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes values create --attribute-id "$attr_id" --value test_reg_res_attr__val_1 --json | jq -r '.id') + export ATTR_VAL_1_ID + ATTR_VAL_1_FQN=$(./otdfctl $HOST $WITH_CREDS policy attributes values get --id "$ATTR_VAL_1_ID" --json | jq -r '.fqn') + export ATTR_VAL_1_FQN + ATTR_VAL_2_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes values create --attribute-id "$attr_id" --value test_reg_res_attr__val_2 --json | jq -r '.id') + export ATTR_VAL_2_ID + ATTR_VAL_2_FQN=$(./otdfctl $HOST $WITH_CREDS policy attributes values get --id "$ATTR_VAL_2_ID" --json | jq -r '.fqn') + export ATTR_VAL_2_FQN + + echo "FQN: $ATTR_VAL_1_FQN" +} + +setup() { + load "${BATS_LIB_PATH}/bats-support/load.bash" + load "${BATS_LIB_PATH}/bats-assert/load.bash" + + # invoke binary with credentials + run_otdfctl_reg_res () { + run sh -c "./otdfctl $HOST $WITH_CREDS policy registered-resources $*" + } + run_otdfctl_reg_res_values () { + run sh -c "./otdfctl $HOST $WITH_CREDS policy registered-resources values $*" + } +} + +teardown_file() { + # remove the registered resource used in registered resource values tests + ./otdfctl $HOST $WITH_CREDS policy registered-resources delete --id "$RR_ID" --force + + # remove the custom action used in registered resource values tests + ./otdfctl $HOST $WITH_CREDS policy actions delete --id "$CUSTOM_ACTION_ID" --force + + # remove the namespace and cascade delete attributes and values used in registered resource values tests + ./otdfctl $HOST $WITH_CREDS policy attributes namespaces unsafe delete --id "$NS_ID" --force + + # clear out all test env vars + unset HOST WITH_CREDS RR_NAME RR_ID CUSTOM_ACTION_NAME CUSTOM_ACTION_ID READ_ACTION_NAME READ_ACTION_ID NS_NAME NS_ID ATTR_NAME ATTR_VAL_1_ID ATTR_VAL_1_FQN ATTR_VAL_2_ID ATTR_VAL_2_FQN +} + +@test "Create a registered resource - Good" { + # with a namespace + run_otdfctl_reg_res create --name test_create_rr --namespace "$NS_ID" + assert_output --partial "SUCCESS" + assert_line --regexp "Name.*test_create_rr" + assert_line --regexp "Namespace.*https://$NS_NAME" + assert_output --partial "Id" + assert_output --partial "Created At" + assert_line --partial "Updated At" + + # cleanup + created_id=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + run_otdfctl_reg_res delete --id $created_id --force + + # without a namespace (should default to un-namespaced) + run_otdfctl_reg_res create --name test_create_rr_no_ns --json + assert_success + [ "$(echo "$output" | jq -r '.name')" = "test_create_rr_no_ns" ] + # ensure namespace is empty for un-namespaced resources + [ "$(echo "$output" | jq -r '.namespace.fqn // empty')" = "" ] + + created_id=$(echo "$output" | jq -r '.id') + run_otdfctl_reg_res delete --id $created_id --force +} + +@test "Create a registered resource - Bad" { + # bad resource names + run_otdfctl_reg_res create --name ends_underscored_ --namespace "$NS_ID" + assert_failure + run_otdfctl_reg_res create --name -first-char-hyphen --namespace "$NS_ID" + assert_failure + run_otdfctl_reg_res create --name inval!d.chars --namespace "$NS_ID" + assert_failure + + # missing flags + run_otdfctl_reg_res create + assert_failure + assert_output --partial "Flag '--name' is required" + + # conflict + run_otdfctl_reg_res create --name test_create_rr_conflict --namespace "$NS_ID" + assert_output --partial "SUCCESS" + created_id=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + run_otdfctl_reg_res create --name test_create_rr_conflict --namespace "$NS_ID" + assert_failure + assert_output --partial "already_exists" + + # cleanup + run_otdfctl_reg_res delete --id $created_id --force +} + +@test "Get a registered resource - Good" { + # setup a resource to get + run_otdfctl_reg_res create --name test_get_rr --namespace "$NS_ID" + assert_success + created_id=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + + # get by id + run_otdfctl_reg_res get --id "$created_id" --json + assert_success + [ "$(echo "$output" | jq -r '.id')" = "$created_id" ] + [ "$(echo "$output" | jq -r '.name')" = "test_get_rr" ] + + # get by name + run_otdfctl_reg_res get --name test_get_rr --json + assert_success + [ "$(echo "$output" | jq -r '.id')" = "$created_id" ] + [ "$(echo "$output" | jq -r '.name')" = "test_get_rr" ] + + # get by name + namespace ID + run_otdfctl_reg_res get --name test_get_rr --namespace "$NS_ID" --json + assert_success + [ "$(echo "$output" | jq -r '.id')" = "$created_id" ] + [ "$(echo "$output" | jq -r '.name')" = "test_get_rr" ] + [ "$(echo "$output" | jq -r '.namespace.fqn')" = "https://$NS_NAME" ] + + # get by name + namespace FQN + run_otdfctl_reg_res get --name test_get_rr --namespace "https://$NS_NAME" --json + assert_success + [ "$(echo "$output" | jq -r '.id')" = "$created_id" ] + [ "$(echo "$output" | jq -r '.name')" = "test_get_rr" ] + + # cleanup + run_otdfctl_reg_res delete --id $created_id --force +} + +@test "Get a registered resource - Bad" { + run_otdfctl_reg_res get + assert_failure + assert_output --partial "Either 'id' or 'name' must be provided" + + run_otdfctl_reg_res get --id 'not_a_uuid' + assert_failure + assert_output --partial "must be a valid UUID" +} + +@test "List registered resources" { + # setup registered resources to list + run_otdfctl_reg_res create --name test_list_rr_1 --namespace "$NS_ID" + reg_res1_id=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + run_otdfctl_reg_res create --name test_list_rr_2 --namespace "$NS_ID" + reg_res2_id=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + + run_otdfctl_reg_res list + assert_success + assert_output --partial "$reg_res1_id" + assert_output --partial "test_list_rr_1" + assert_output --partial "$reg_res2_id" + assert_output --partial "test_list_rr_2" + assert_output --partial "Total" + assert_line --regexp "Current Offset.*0" + + run_otdfctl_reg_res list --json + assert_success + assert_output --partial "$reg_res1_id" + assert_output --partial "test_list_rr_1" + assert_output --partial "$reg_res2_id" + assert_output --partial "test_list_rr_2" + [[ $(echo "$output" | jq -r '.pagination.total') -ge 1 ]] + + # cleanup + run_otdfctl_reg_res delete --id $reg_res1_id --force + run_otdfctl_reg_res delete --id $reg_res2_id --force +} + +@test "Update registered resource" { + # setup a resource to update + run_otdfctl_reg_res create --name test_update_rr --namespace "$NS_ID" + assert_success + created_id=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + + # force replace labels + run_otdfctl_reg_res update --id "$created_id" -l key=other --force-replace-labels + assert_success + assert_line --regexp "Id.*$created_id" + assert_line --regexp "Name.*test_update_rr" + assert_line --regexp "Labels.*key: other" + refute_output --regexp "Labels.*key: value" + refute_output --regexp "Labels.*test: true" + refute_output --regexp "Labels.*test: true" + + # renamed + run_otdfctl_reg_res update --id "$created_id" --name test_renamed_rr + assert_success + assert_line --regexp "Id.*$created_id" + assert_line --regexp "Name.*test_renamed_rr" + refute_output --regexp "Name.*test_update_rr" + + # cleanup + run_otdfctl_reg_res delete --id $created_id --force +} + +@test "Delete registered resource - Good" { + # setup a resource to delete + run_otdfctl_reg_res create --name test_delete_rr --namespace "$NS_ID" + created_id=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + + run_otdfctl_reg_res delete --id "$created_id" --force + assert_success +} + +@test "Delete registered resource - Bad" { + # no id + run_otdfctl_reg_res delete + assert_failure + assert_output --partial "Flag '--id' is required" + + # invalid id + run_otdfctl_reg_res delete --id 'not_a_uuid' + assert_failure + assert_output --partial "must be a valid UUID" +} + +# Tests for registered resource values + +@test "Create a registered resource value - Good" { + # simple by resource ID + run_otdfctl_reg_res_values create --resource "$RR_ID" --value test_create_rr_val + assert_output --partial "SUCCESS" + assert_line --regexp "Value.*test_create_rr_val" + assert_output --partial "Id" + assert_output --partial "Created At" + assert_line --partial "Updated At" + created_id_simple=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + + # simple by resource name + run_otdfctl_reg_res_values create --resource "$RR_NAME" --value test_create_rr_val_by_res_name + assert_output --partial "SUCCESS" + assert_line --regexp "Value.*test_create_rr_val" + assert_output --partial "Id" + assert_output --partial "Created At" + assert_line --partial "Updated At" + created_id_simple_by_res_name=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + + # with action attribute values + # TODO(namespaced-actions): switch custom action identifier back to name when RR action resolution is namespaced. + run_otdfctl_reg_res_values create --resource "$RR_ID" --value test_create_rr_val_with_action_attr_vals --action-attribute-value "\"$READ_ACTION_ID;$ATTR_VAL_1_FQN\"" --action-attribute-value "\"$CUSTOM_ACTION_ID;$ATTR_VAL_2_ID\"" --json + assert_success + [ "$(echo "$output" | jq -r '.id')" != "" ] + [ "$(echo "$output" | jq -r '.value')" = "test_create_rr_val_with_action_attr_vals" ] + [ "$(echo "$output" | jq -r 'any(.action_attribute_values[]; .action.id == "'"$READ_ACTION_ID"'" and .action.name == "'"$READ_ACTION_NAME"'" and .attribute_value.id == "'"$ATTR_VAL_1_ID"'" and .attribute_value.fqn == "'"$ATTR_VAL_1_FQN"'")')" = "true" ] + [ "$(echo "$output" | jq -r 'any(.action_attribute_values[]; .action.id == "'"$CUSTOM_ACTION_ID"'" and .action.name == "'"$CUSTOM_ACTION_NAME"'" and .attribute_value.id == "'"$ATTR_VAL_2_ID"'" and .attribute_value.fqn == "'"$ATTR_VAL_2_FQN"'")')" = "true" ] + created_id_with_action_attr_vals=$(echo "$output" | jq -r '.id') + + # cleanup + run_otdfctl_reg_res_values delete --id $created_id_simple --force + run_otdfctl_reg_res_values delete --id $created_id_simple_by_res_name --force + run_otdfctl_reg_res_values delete --id $created_id_with_action_attr_vals --force +} + +@test "Create a registered resource value - Bad" { + # bad resource value names + run_otdfctl_reg_res_values create --resource "$RR_ID" --value ends_underscored_ + assert_failure + run_otdfctl_reg_res_values create --resource "$RR_ID" --value -first-char-hyphen + assert_failure + run_otdfctl_reg_res_values create --resource "$RR_ID" --value inval!d.chars + assert_failure + + # missing flag + run_otdfctl_reg_res_values create + assert_failure + assert_output --partial "Flag '--resource' is required" + run_otdfctl_reg_res_values create --resource "$RR_ID" + assert_failure + assert_output --partial "Flag '--value' is required" + + # bad action attribute value arg separator (not a semicolon) + run_otdfctl_reg_res_values create --resource "$RR_ID" --value test_create_rr_val_bad_aav --action-attribute-value "\"$READ_ACTION_ID:$ATTR_VAL_1_ID\"" + assert_failure + assert_output --partial "Invalid action attribute value arg format" + + # non-existent resource name + run_otdfctl_reg_res_values create --resource invalid_rr --value test_create_rr_val_bad_aav_action_name + assert_failure + assert_output --partial "Failed to find registered resource (name: invalid_rr)" + + # conflict + run_otdfctl_reg_res_values create --resource "$RR_ID" --value test_create_rr_val_conflict + assert_output --partial "SUCCESS" + created_id=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + run_otdfctl_reg_res_values create --resource "$RR_ID" --value test_create_rr_val_conflict + assert_failure + assert_output --partial "already_exists" + + # cleanup + run_otdfctl_reg_res_values delete --id $created_id --force +} + +@test "Get a registered resource value - Good" { + # setup a resource value to get + run_otdfctl_reg_res_values create --resource "$RR_ID" --value test_get_rr_val --action-attribute-value "\"$READ_ACTION_ID;$ATTR_VAL_1_ID\"" + assert_success + created_id=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + + # get by id + run_otdfctl_reg_res_values get --id "$created_id" --json + assert_success + [ "$(echo "$output" | jq -r '.id')" = "$created_id" ] + [ "$(echo "$output" | jq -r '.value')" = "test_get_rr_val" ] + [ "$(echo "$output" | jq -r '.action_attribute_values[0].action.id')" = "$READ_ACTION_ID" ] + [ "$(echo "$output" | jq -r '.action_attribute_values[0].action.name')" = "$READ_ACTION_NAME" ] + [ "$(echo "$output" | jq -r '.action_attribute_values[0].attribute_value.id')" = "$ATTR_VAL_1_ID" ] + [ "$(echo "$output" | jq -r '.action_attribute_values[0].attribute_value.fqn')" = "$ATTR_VAL_1_FQN" ] + + # get by fqn + run_otdfctl_reg_res_values get --fqn "https://reg_res/$RR_NAME/value/test_get_rr_val" --json + assert_success + [ "$(echo "$output" | jq -r '.id')" = "$created_id" ] + [ "$(echo "$output" | jq -r '.value')" = "test_get_rr_val" ] + [ "$(echo "$output" | jq -r '.action_attribute_values[0].action.id')" = "$READ_ACTION_ID" ] + [ "$(echo "$output" | jq -r '.action_attribute_values[0].action.name')" = "$READ_ACTION_NAME" ] + [ "$(echo "$output" | jq -r '.action_attribute_values[0].attribute_value.id')" = "$ATTR_VAL_1_ID" ] + [ "$(echo "$output" | jq -r '.action_attribute_values[0].attribute_value.fqn')" = "$ATTR_VAL_1_FQN" ] + + # cleanup + run_otdfctl_reg_res_values delete --id $created_id --force +} + +@test "Get a registered resource value - Bad" { + run_otdfctl_reg_res_values get + assert_failure + assert_output --partial "Either 'id' or 'fqn' must be provided" + + # invalud id + run_otdfctl_reg_res_values get --id 'not_a_uuid' + assert_failure + assert_output --partial "must be a valid UUID" + + # invalid fqn + run_otdfctl_reg_res_values get --fqn 'not_a_fqn' + assert_failure + assert_output --partial "must be a valid URI" +} + +@test "List registered resource values - Good" { + # setup values to list + run_otdfctl_reg_res_values create --resource "$RR_ID" --value test_list_rr_val_1 --action-attribute-value "\"$READ_ACTION_ID;$ATTR_VAL_1_ID\"" + reg_res_val1_id=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + run_otdfctl_reg_res_values create --resource "$RR_ID" --value test_list_rr_val_2 + reg_res_val2_id=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + + # by resource ID + run_otdfctl_reg_res_values list --resource "$RR_ID" + assert_success + assert_output --partial "$reg_res_val1_id" + assert_output --partial "test_list_rr_val_1" + # check for partial FQN due to possible trimmed output + assert_output --partial "$READ_ACTION_NAME -> https://$NS_NAME/attr/$ATTR_NAME" + assert_output --partial "$reg_res_val2_id" + assert_output --partial "test_list_rr_val_2" + assert_output --partial "Total" + assert_line --regexp "Current Offset.*0" + + # by resource name + run_otdfctl_reg_res_values list --resource "$RR_NAME" + assert_success + assert_output --partial "$reg_res_val1_id" + assert_output --partial "test_list_rr_val_1" + assert_output --partial "$READ_ACTION_NAME -> https://$NS_NAME/attr/$ATTR_NAME" + assert_output --partial "$reg_res_val2_id" + assert_output --partial "test_list_rr_val_2" + assert_output --partial "Total" + assert_line --regexp "Current Offset.*0" + + run_otdfctl_reg_res_values list --resource "$RR_NAME" --json + assert_success + assert_output --partial "$reg_res_val1_id" + assert_output --partial "test_list_rr_val_1" + assert_output --partial "https://$NS_NAME/attr/$ATTR_NAME/value/test_reg_res_attr__val_1" + assert_output --partial "$reg_res_val2_id" + assert_output --partial "test_list_rr_val_2" + [[ $(echo "$output" | jq -r '.pagination.total') -ge 1 ]] + + # cleanup + run_otdfctl_reg_res_values delete --id $reg_res_val1_id --force + run_otdfctl_reg_res_values delete --id $reg_res_val2_id --force +} + +@test "List registered resource values - Bad" { + # non-existent resource name + run_otdfctl_reg_res_values list --resource 'invalid_rr' + assert_failure + assert_output --partial "Failed to find registered resource (name: invalid_rr)" +} + +@test "Update registered resource values" { + # setup a resource value to update + run_otdfctl_reg_res_values create --resource "$RR_ID" --value test_update_rr_val --action-attribute-value "\"$READ_ACTION_ID;$ATTR_VAL_1_ID\"" + assert_success + created_id=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + + # force replace labels + run_otdfctl_reg_res_values update --id "$created_id" -l key=other --force-replace-labels + assert_success + assert_line --regexp "Id.*$created_id" + assert_line --regexp "Value.*test_update_rr_val" + assert_line --regexp "Labels.*key: other" + refute_output --regexp "Labels.*key: value" + refute_output --regexp "Labels.*test: true" + refute_output --regexp "Labels.*test: true" + + # renamed + run_otdfctl_reg_res_values update --id "$created_id" --value test_renamed_rr_val + assert_success + assert_line --regexp "Id.*$created_id" + assert_line --regexp "Value.*test_renamed_rr_val" + refute_output --regexp "Value.*test_update_rr_val" + + # ensure previous updates without action attribute value args did not clear action attribute values + run_otdfctl_reg_res_values get --id "$created_id" --json + [ "$(echo "$output" | jq -r 'any(.action_attribute_values[]; .action.id == "'"$READ_ACTION_ID"'" and .action.name == "'"$READ_ACTION_NAME"'" and .attribute_value.id == "'"$ATTR_VAL_1_ID"'" and .attribute_value.fqn == "'"$ATTR_VAL_1_FQN"'")')" = "true" ] + + # update action attribute values + # TODO(namespaced-actions): switch action identifiers back to names when RR action resolution is namespaced. + run_otdfctl_reg_res_values update --id "$created_id" --action-attribute-value "\"$READ_ACTION_ID;$ATTR_VAL_1_FQN\"" --action-attribute-value "\"$CUSTOM_ACTION_ID;$ATTR_VAL_2_ID\"" --force --json + assert_success + [ "$(echo "$output" | jq -r '.id')" = "$created_id" ] + [ "$(echo "$output" | jq -r 'any(.action_attribute_values[]; .action.id == "'"$READ_ACTION_ID"'" and .action.name == "'"$READ_ACTION_NAME"'" and .attribute_value.id == "'"$ATTR_VAL_1_ID"'" and .attribute_value.fqn == "'"$ATTR_VAL_1_FQN"'")')" = "true" ] + [ "$(echo "$output" | jq -r 'any(.action_attribute_values[]; .action.id == "'"$CUSTOM_ACTION_ID"'" and .action.name == "'"$CUSTOM_ACTION_NAME"'" and .attribute_value.id == "'"$ATTR_VAL_2_ID"'" and .attribute_value.fqn == "'"$ATTR_VAL_2_FQN"'")')" = "true" ] + + # cleanup + run_otdfctl_reg_res_values delete --id $created_id --force +} + +@test "Delete registered resource value - Good" { + # setup a value to delete + run_otdfctl_reg_res_values create --resource "$RR_ID" --value test_delete_rr_val + created_id=$(echo "$output" | grep Id | awk -F'│' '{print $3}' | xargs) + + run_otdfctl_reg_res_values delete --id "$created_id" --force + assert_success +} + +@test "Delete registered resource value - Bad" { + # no id + run_otdfctl_reg_res_values delete + assert_failure + assert_output --partial "Flag '--id' is required" + + # invalid id + run_otdfctl_reg_res_values delete --id 'not_a_uuid' + assert_failure + assert_output --partial "must be a valid UUID" +} diff --git a/otdfctl/e2e/resize_terminal.sh b/otdfctl/e2e/resize_terminal.sh new file mode 100755 index 0000000000..a2a65680ff --- /dev/null +++ b/otdfctl/e2e/resize_terminal.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +#### +# Make sure we have a terminal size large enough to test table output +#### + +## Accepts two arguments: rows and columns (both integers) + +# Default terminal size +DEFAULT_ROWS=40 +DEFAULT_COLUMNS=200 + +# Set rows and columns to the defaults or use the provided arguments +ROWS=${1:-$DEFAULT_ROWS} +COLUMNS=${2:-$DEFAULT_COLUMNS} + +set_terminal_size_linux() { + if command -v resize &> /dev/null; then + resize -s "$ROWS" "$COLUMNS" + else + export COLUMNS="$COLUMNS" + export LINES="$ROWS" + fi +} + +set_terminal_size_mac() { + printf '\e[8;%d;%dt' "$ROWS" "$COLUMNS" +} + +set_terminal_size_windows() { + if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + printf '\e[8;%d;%dt' "$ROWS" "$COLUMNS" + else + cmd.exe /c "mode con: cols=$COLUMNS lines=$ROWS" + fi +} + +# Detect the OS and set the terminal size appropriately +case "$OSTYPE" in + linux*) + set_terminal_size_linux + ;; + darwin*) + set_terminal_size_mac + ;; + msys* | cygwin* | win*) + set_terminal_size_windows + ;; + *) + echo "Unsupported OS: $OSTYPE" + ;; +esac \ No newline at end of file diff --git a/otdfctl/e2e/resource-mapping-groups.bats b/otdfctl/e2e/resource-mapping-groups.bats new file mode 100644 index 0000000000..e9bd93ff15 --- /dev/null +++ b/otdfctl/e2e/resource-mapping-groups.bats @@ -0,0 +1,136 @@ +#!/usr/bin/env bats + +# Tests for resource mapping groups + +setup_file() { + export WITH_CREDS='--with-client-creds-file ./creds.json' + export HOST='--host http://localhost:8080' + + # Create two namespaced values to be used in other tests + NS_NAME="resource-mapping-groups.io" + export NS_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes namespaces create -n "$NS_NAME" --json | jq -r '.id') + NS_NAME2="resource-mapping-groups-2.io" + export NS2_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes namespaces create -n "$NS_NAME2" --json | jq -r '.id') + ATTR_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes create --namespace "$NS_ID" --name attr1 --rule ANY_OF --json | jq -r '.id') + # Name is prefixed with RMG to avoid conflicts across tests when running in parallel + export RMG_VAL1_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes values create --attribute-id "$ATTR_ID" --value val1 --json | jq -r '.id') + + # Create a resource mapping group + export RMG1_NAME="rmgrp-test" + export RMG1_ID=$(./otdfctl $HOST $WITH_CREDS policy resource-mapping-groups create --namespace-id "$NS_ID" --name "$RMG1_NAME" --json | jq -r '.id') + + # Create a couple resource mappings to val1 - comma separated + export RM1_TERMS="valueone,valuefirst,first,one" + export RM1_ID=$(./otdfctl $HOST $WITH_CREDS policy resource-mappings create --attribute-value-id "$RMG_VAL1_ID" --terms "$RM1_TERMS" --group-id "$RMG1_ID" --json | jq -r '.id') + export RM1_OTHER_TERMS="otherone,othervaluefirst,otherfirst,otherone" + export RM1_OTHER_ID=$(./otdfctl $HOST $WITH_CREDS policy resource-mappings create --attribute-value-id "$RMG_VAL1_ID" --terms "$RM1_OTHER_TERMS" --group-id "$RMG1_ID" --json | jq -r '.id') +} + +setup() { + load "${BATS_LIB_PATH}/bats-support/load.bash" + load "${BATS_LIB_PATH}/bats-assert/load.bash" + + # invoke binary with credentials + run_otdfctl_rmg () { + run sh -c "./otdfctl $HOST $WITH_CREDS policy resource-mapping-groups $*" + } + +} + +teardown_file() { + # remove the created namespace with all underneath upon test suite completion + ./otdfctl $HOST $WITH_CREDS policy attributes namespaces unsafe delete --force --id "$NS_ID" + ./otdfctl $HOST $WITH_CREDS policy attributes namespaces unsafe delete --force --id "$NS2_ID" + + unset HOST WITH_CREDS RMG_VAL1_ID NS_ID NS2_ID RM1_TERMS RM1_ID RM1_OTHER_TERMS RM1_OTHER_ID RMG1_NAME RMG1_ID +} + +@test "Create resource mapping group" { + # create with multiple terms flags instead of comma-separated + run_otdfctl_rmg create --namespace-id "$NS_ID" --name rmgrp1 + assert_success + assert_output --partial "rmgrp1" + assert_line --regexp "Namespace Id.*$NS_ID" + + # ns id flag must be uuid + run_otdfctl_rmg create --namespace-id "something" --name testing + assert_failure + assert_output --partial "must be a valid UUID" + + # name is required + run_otdfctl_rmg create --namespace-id "$NS_ID" + assert_failure + assert_output --partial "Flag '--name' is required" +} + +@test "Get resource mapping group" { + # table + run_otdfctl_rmg get --id "$RMG1_ID" + assert_success + assert_line --regexp "Id.*$RMG1_ID" + assert_line --regexp "Namespace Id.*$NS_ID" + assert_line --regexp "Name.*$RMG1_NAME" + + # json + run_otdfctl_rmg get --id "$RMG1_ID" --json + assert_success + [ $(echo $output | jq -r '.id') = "$RMG1_ID" ] + [ $(echo $output | jq -r '.namespace_id') = "$NS_ID" ] + [ $(echo $output | jq -r '.name') = "$RMG1_NAME" ] + + # id required + run_otdfctl_rmg get + assert_failure + assert_output --partial "is required" + run_otdfctl_rmg get --id "test" + assert_failure + assert_output --partial "must be a valid UUID" +} + +@test "Update a resource mapping group" { + NEW_RMG_ID=$(./otdfctl $HOST $WITH_CREDS policy resource-mapping-groups create --namespace-id "$NS_ID" --name test-rsmg --json | jq -r '.id') + + # replace the terms + run_otdfctl_rmg update --id "$NEW_RMG_ID" --name "new-rsmg-name" + assert_success + refute_output --partial "test-rsmg" + assert_output --partial "new-rsmg-name" + assert_output --partial "$NS_ID" + + # reassign the namespace being mapped + run_otdfctl_rmg update --id "$NEW_RMG_ID" --namespace-id "$NS2_ID" + assert_success + refute_output --partial "test-rsmg" + assert_output --partial "new-rsmg-name" + refute_output --partial "$NS_ID" + assert_output --partial "$NS2_ID" +} + +@test "List resource mapping groups" { + run_otdfctl_rmg list + assert_success + assert_output --partial "$RMG1_ID" + assert_output --partial "$NS_ID" + assert_output --partial "$RMG1_NAME" + assert_output --partial "Total" + assert_line --regexp "Current Offset.*0" + + run_otdfctl_rmg list --json + assert_success + [[ "$(echo "$output" | jq -r '.resource_mapping_groups | length')" -ge 1 ]] + found_rmg=$(echo "$output" | jq -c --arg id "$RMG1_ID" '.resource_mapping_groups as $a | ($a | map(.id) | index($id)) as $i | $a[$i]') + assert_equal "$(echo "$found_rmg" | jq -r '.id')" "$RMG1_ID" + assert_equal "$(echo "$found_rmg" | jq -r '.name')" "$RMG1_NAME" + [[ "$(echo "$output" | jq -r '.pagination.total')" -ge 1 ]] + assert_equal "$(echo "$output" | jq -r '.pagination.current_offset')" "null" + assert_equal "$(echo "$output" | jq -r '.pagination.next_offset')" "null" +} + +@test "Delete resource mapping group" { + # --force to avoid indefinite hang waiting for confirmation + run_otdfctl_rmg delete --id "$RMG1_ID" --force + assert_success + assert_line --regexp "Id.*$RMG1_ID" + assert_line --regexp "Namespace Id.*$NS_ID" + assert_line --regexp "Name.*$RMG1_NAME" +} diff --git a/otdfctl/e2e/resource-mapping.bats b/otdfctl/e2e/resource-mapping.bats new file mode 100755 index 0000000000..ff29b33c25 --- /dev/null +++ b/otdfctl/e2e/resource-mapping.bats @@ -0,0 +1,154 @@ +#!/usr/bin/env bats + +# Tests for resource mappings + +setup_file() { + export WITH_CREDS='--with-client-creds-file ./creds.json' + export HOST='--host http://localhost:8080' + + # Create two namespaced values to be used in other tests + NS_NAME="resource-mappings.io" + export NS_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes namespaces create -n "$NS_NAME" --json | jq -r '.id') + ATTR_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes create --namespace "$NS_ID" --name attr1 --rule ANY_OF --json | jq -r '.id') + # Names prefixed with RM to avoid conflicts across tests when running in parallel + export RM_VAL1_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes values create --attribute-id "$ATTR_ID" --value val1 --json | jq -r '.id') + export RM_VAL2_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes values create --attribute-id "$ATTR_ID" --value val2 --json | jq -r '.id') + + # Create a single resource mapping to val1 - comma separated + export RM1_TERMS="valueone,valuefirst,first,one" + export RM1_ID=$(./otdfctl $HOST $WITH_CREDS policy resource-mappings create --attribute-value-id "$RM_VAL1_ID" --terms "$RM1_TERMS" --json | jq -r '.id') + + # Create a resource mapping group + export RMG1_ID=$(./otdfctl $HOST $WITH_CREDS policy resource-mapping-groups create --namespace-id "$NS_ID" --name rmgrp-test --json | jq -r '.id') + export RMG2_ID=$(./otdfctl $HOST $WITH_CREDS policy resource-mapping-groups create --namespace-id "$NS_ID" --name rmgrp-test-2 --json | jq -r '.id') +} + +setup() { + load "${BATS_LIB_PATH}/bats-support/load.bash" + load "${BATS_LIB_PATH}/bats-assert/load.bash" + + # invoke binary with credentials + run_otdfctl_rm () { + run sh -c "./otdfctl $HOST $WITH_CREDS policy resource-mappings $*" + } + +} + +teardown_file() { + # remove the created namespace with all underneath upon test suite completion + ./otdfctl $HOST $WITH_CREDS policy attributes namespaces unsafe delete --force --id "$NS_ID" + + unset HOST WITH_CREDS RM_VAL1_ID RM_VAL2_ID NS_ID RM1_TERMS RM1_ID RMG1_ID RMG2_ID +} + +@test "Create resource mapping" { + # create with multiple terms flags instead of comma-separated + run_otdfctl_rm create --attribute-value-id "$RM_VAL2_ID" --terms "second" --terms "TWO" + assert_success + assert_output --partial "second" + assert_output --partial "TWO" + assert_line --regexp "Attribute Value Id.*$RM_VAL2_ID" + + # value id flag must be uuid + run_otdfctl_rm create --attribute-value-id "val2" --terms "testing" + assert_failure + assert_output --partial "must be a valid UUID" + + # terms are required + run_otdfctl_rm create --attribute-value-id $RM_VAL2_ID + assert_failure + assert_output --partial "must have at least 1 non-empty values" +} + +@test "Create resource mapping in a group" { + # create with multiple terms flags instead of comma-separated + run_otdfctl_rm create --attribute-value-id "$RM_VAL2_ID" --terms "second,TWO" --group-id "$RMG1_ID" + assert_success + assert_output --partial "second" + assert_output --partial "TWO" + assert_line --regexp "Attribute Value Id.*$RM_VAL2_ID" + assert_line --regexp "Group Id.*$RMG1_ID" + + # group id flag must be uuid + run_otdfctl_rm create --attribute-value-id "$RM_VAL2_ID" --terms "testing" --group-id "grp1" + assert_failure + assert_output --partial "must be a valid UUID" +} + +@test "Get resource mapping" { + spaced_terms=$(echo $RM1_TERMS | sed 's/,/, /g') + # table + run_otdfctl_rm get --id "$RM1_ID" + assert_success + assert_line --regexp "Id.*$RM1_ID" + assert_line --regexp "Attribute Value Id.*$RM_VAL1_ID" + assert_line --regexp "Terms.*$spaced_terms" + + # json + run_otdfctl_rm get --id "$RM1_ID" --json + assert_success + [ $(echo $output | jq -r '.id') = "$RM1_ID" ] + [ $(echo $output | jq -r '.attribute_value.id') = "$RM_VAL1_ID" ] + [ $(echo $output | jq -r '.terms | join (",")') = "$RM1_TERMS" ] + + # id required + run_otdfctl_rm get + assert_failure + assert_output --partial "is required" + run_otdfctl_rm get --id "test" + assert_failure + assert_output --partial "must be a valid UUID" +} + +@test "Update a resource mapping" { + NEW_RM_ID=$(./otdfctl $HOST $WITH_CREDS policy resource-mappings create --attribute-value-id "$RM_VAL2_ID" --terms test --terms found --group-id "$RMG1_ID" --json | jq -r '.id') + + # replace the terms + run_otdfctl_rm update --id "$NEW_RM_ID" --terms replaced,new + assert_success + refute_output --partial "test" + refute_output --partial "found" + assert_output --partial "replaced" + assert_output --partial "new" + assert_output --partial "$RM_VAL2_ID" + + # reassign the attribute value being mapped + run_otdfctl_rm update --id "$NEW_RM_ID" --attribute-value-id "$RM_VAL1_ID" + assert_success + refute_output --partial "test" + refute_output --partial "found" + assert_output --partial "replaced" + assert_output --partial "new" + refute_output --partial "$RM_VAL2_ID" + assert_output --partial "$RM_VAL1_ID" +} + +@test "List resource mappings" { + run_otdfctl_rm list + assert_success + assert_output --partial "$RM1_ID" + assert_output --partial "$RM_VAL1_ID" + assert_output --partial "valueone, valuefirst, first" + assert_output --partial "Total" + assert_line --regexp "Current Offset.*0" + + run_otdfctl_rm list --json + assert_success + [[ "$(echo "$output" | jq -r '.resource_mappings | length')" -ge 1 ]] + found_rm=$(echo "$output" | jq -c --arg id "$RM1_ID" '.resource_mappings as $a | ($a | map(.id) | index($id)) as $i | $a[$i]') + assert_equal "$(echo "$found_rm" | jq -r '.id')" "$RM1_ID" + assert_equal "$(echo "$found_rm" | jq -r '.attribute_value.id')" "$RM_VAL1_ID" + [[ "$(echo "$output" | jq -r '.pagination.total')" -ge 1 ]] + assert_equal "$(echo "$output" | jq -r '.pagination.current_offset')" "null" + assert_equal "$(echo "$output" | jq -r '.pagination.next_offset')" "null" +} + +@test "Delete resource mapping" { + spaced_terms=$(echo $RM1_TERMS | sed 's/,/, /g') + # --force to avoid indefinite hang waiting for confirmation + run_otdfctl_rm delete --id "$RM1_ID" --force + assert_success + assert_line --regexp "Id.*$RM1_ID" + assert_line --regexp "Attribute Value Id.*$RM_VAL1_ID" + assert_line --regexp "Terms.*$spaced_terms" +} diff --git a/otdfctl/e2e/setup_suite.bash b/otdfctl/e2e/setup_suite.bash new file mode 100755 index 0000000000..f6754fc12c --- /dev/null +++ b/otdfctl/e2e/setup_suite.bash @@ -0,0 +1,31 @@ +#!/bin/bash + +#### +# Make sure we can load BATS dependencies +#### + +setup_suite(){ + + bats_require_minimum_version 1.7.0 + + if [[ "$(which bats)" == *"homebrew"* ]]; then + BATS_LIB_PATH=$(brew --prefix)/lib + fi + + # Check if BATS_LIB_PATH environment variable exists + if [ -z "${BATS_LIB_PATH}" ]; then + # Check if bats bin has homebrew in path name + if [[ "$(which bats)" == *"homebrew"* ]]; then + BATS_LIB_PATH=$(dirname "$(which bats)")/../lib + elif [ -d "/usr/lib/bats-support" ]; then + BATS_LIB_PATH="/usr/lib" + elif [ -d "/usr/local/lib/bats-support" ]; then + # Check if bats-support exists in /usr/local/lib + BATS_LIB_PATH="/usr/local/lib" + fi + fi + echo "BATS_LIB_PATH: $BATS_LIB_PATH" + export BATS_LIB_PATH=$BATS_LIB_PATH + + echo -n '{"clientId":"opentdf","clientSecret":"secret"}' > creds.json +} \ No newline at end of file diff --git a/otdfctl/e2e/subject-condition-sets.bats b/otdfctl/e2e/subject-condition-sets.bats new file mode 100755 index 0000000000..90af46b95b --- /dev/null +++ b/otdfctl/e2e/subject-condition-sets.bats @@ -0,0 +1,200 @@ +#!/usr/bin/env bats + +# Tests for subject condition sets + +setup_file() { + export WITH_CREDS='--with-client-creds-file ./creds.json' + export HOST='--host http://localhost:8080' + + export NS_NAME="subject-condition-sets.net" + export NS_FQN="https://$NS_NAME" + export NS_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes namespaces create -n "$NS_NAME" --json | jq -r '.id') + + export SCS_1='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["marketing"],"subject_external_selector_value":".org.name"},{"operator":1,"subject_external_values":["ShinyThing"],"subject_external_selector_value":".team.name"}],"boolean_operator":1}]}]' + export SCS_2='[{"condition_groups":[{"conditions":[{"operator":3,"subject_external_values":["piedpiper.com","hooli.com"],"subject_external_selector_value":".emailAddress"},{"operator":1,"subject_external_values":["sales"],"subject_external_selector_value":".department"}],"boolean_operator":2}]}]' + export SCS_3='[{"condition_groups":[{"conditions":[{"operator":2,"subject_external_values":["CoolTool","RadService"],"subject_external_selector_value":".team.name"}],"boolean_operator":2}]}]' +} + +setup() { + load "${BATS_LIB_PATH}/bats-support/load.bash" + load "${BATS_LIB_PATH}/bats-assert/load.bash" + + # invoke binary with credentials + run_otdfctl_scs () { + run sh -c "./otdfctl $HOST $WITH_CREDS policy subject-condition-sets $*" + } + + run_delete_scs () { + # Capture the first argument as the ID + local id="$1" + + run sh -c "./otdfctl $HOST $WITH_CREDS policy scs delete --id $id --force" + } +} + +teardown_file() { + # remove the created namespace with all underneath upon test suite completion + ./otdfctl $HOST $WITH_CREDS policy attributes namespaces unsafe delete --force --id "$NS_ID" + + # clear out all test env vars + unset HOST WITH_CREDS NS_NAME NS_FQN NS_ID SCS_1 SCS_2 SCS_3 + + rm scs.json +} + +@test "Create a Subject Condition Set (SCS) - from file" { + echo -n "$SCS_1" > scs.json + + run_otdfctl_scs create --subject-sets-file-json scs.json -l fromfile=true + assert_success + assert_output --partial "Id" + assert_output --partial "Namespace" + assert_output --partial "SubjectSets" + assert_output --partial ".org.name" + assert_output --partial "SUBJECT_MAPPING_OPERATOR_ENUM_IN" + assert_line --regexp "fromfile: true" +} + +@test "Create a Subject Condition Set (SCS) - from flag value JSON" { + run ./otdfctl $HOST $WITH_CREDS policy scs create --subject-sets "$SCS_2" + assert_success + assert_output --partial "Id" + assert_output --partial "Namespace" + assert_output --partial "SubjectSets" + assert_output --partial ".emailAddress" + assert_output --partial "SUBJECT_MAPPING_OPERATOR_ENUM_IN" +} + +@test "Get a SCS" { + CREATED_ID=$(./otdfctl $HOST $WITH_CREDS policy scs add -s "$SCS_3" -l hello=world --json | jq -r '.id') + run_otdfctl_scs get --id "$CREATED_ID" + assert_success + assert_line --regexp "Id.*$CREATED_ID" + assert_output --partial "Namespace" + assert_output --partial "Labels" + assert_output --partial "hello: world" + assert_output --partial "Created At" + assert_output --partial "Updated At" + assert_output --partial ".team.name" + assert_output --partial "SUBJECT_MAPPING_OPERATOR_ENUM_NOT_IN" + + run_delete_scs "$CREATED_ID" +} + +@test "Update a SCS - from flag value JSON" { + echo -n "$SCS_1" > scs.json + CREATED_ID=$(./otdfctl $HOST $WITH_CREDS policy scs create --subject-sets-file-json scs.json -l fromfile=true --json | jq -r '.id') + + run ./otdfctl $HOST $WITH_CREDS policy scs update --subject-sets "$SCS_2" --id "$CREATED_ID" + assert_success + assert_output --partial ".emailAddress" + assert_output --partial "SUBJECT_MAPPING_OPERATOR_ENUM_IN" + assert_output --partial "fromfile: true" + refute_output --partial ".org.name" + + run_delete_scs "$CREATED_ID" +} + +@test "Update a SCS - from file" { + CREATED_ID=$(./otdfctl $HOST $WITH_CREDS policy scs create --subject-sets "$SCS_2" -l fromfile=false --json | jq -r '.id') + + echo -n "$SCS_3" > scs.json + + run ./otdfctl $HOST $WITH_CREDS policy scs update --subject-sets-file-json scs.json --id "$CREATED_ID" -l fromfile=true + assert_success + refute_output --partial ".emailAddress" + refute_output --partial "SUBJECT_MAPPING_OPERATOR_ENUM_IN" + assert_output --partial ".team.name" + assert_output --partial "fromfile: true" + assert_output --partial "SUBJECT_MAPPING_OPERATOR_ENUM_NOT_IN" + + run_delete_scs "$CREATED_ID" +} + +@test "List SCS" { + CREATED_ID=$(./otdfctl $HOST $WITH_CREDS policy scs create --subject-sets "$SCS_2" -l fromfile=false --json | jq -r '.id') + + run_otdfctl_scs list + assert_success + assert_output --partial "$CREATED_ID" + assert_output --partial "Total" + assert_line --regexp "Current Offset.*0" + + run_otdfctl_scs list --json + assert_success + assert_output --partial ".department" + assert_output --partial ".emailAddress" + assert_output --partial ".team.name" + assert_output --partial ".org.name" + matched_object=$(echo "$output" | jq -r --arg id "$CREATED_ID" '.subject_condition_sets[] | select(.id == $id)') + [ $(echo "$matched_object" | jq -r '.subject_sets[0].condition_groups[0].conditions[0].subject_external_values | contains(["piedpiper.com"])') = "true" ] + [ $(echo "$matched_object" | jq -r '.metadata.labels.fromfile') = "false" ] + [[ $(echo "$output" | jq -r ".pagination.total") -ge 1 ]] + + + # validate deletion + run_delete_scs "$CREATED_ID" + assert_success + assert_output --partial "$CREATED_ID" +} + +@test "Create a SCS with namespace id" { + run ./otdfctl $HOST $WITH_CREDS policy scs create --subject-sets "$SCS_2" --namespace "$NS_ID" + assert_output --partial "Id" + assert_output --partial "Namespace" + assert_output --partial "SubjectSets" +} + +@test "Create a SCS with namespace FQN" { + run ./otdfctl $HOST $WITH_CREDS policy scs create --subject-sets "$SCS_2" --namespace "$NS_FQN" + assert_output --partial "Id" + assert_output --partial "Namespace" + assert_output --partial "SubjectSets" +} + +@test "List SCS with namespace filter" { + test_ns_name="scs-list-$BATS_TEST_NUMBER.net" + test_ns_fqn="https://$test_ns_name" + test_ns_id=$(./otdfctl $HOST $WITH_CREDS policy attributes namespaces create -n "$test_ns_name" --json | jq -r '.id') + CREATED_ID=$(./otdfctl $HOST $WITH_CREDS policy scs create --subject-sets "$SCS_2" --namespace "$test_ns_id" --json | jq -r '.id') + + run_otdfctl_scs list --namespace "$test_ns_id" + assert_success + assert_output --partial "$CREATED_ID" + assert_output --partial "Total" + + run_otdfctl_scs list --namespace "$test_ns_id" --json + assert_success + assert_equal "$(echo "$output" | jq -r --arg id "$CREATED_ID" '.subject_condition_sets[] | select(.id == $id) | .id')" "$CREATED_ID" + # Ensure only SCS from the filtered namespace are returned + assert_equal "$(echo "$output" | jq -r --arg ns "$test_ns_id" '[.subject_condition_sets[] | select(.namespace.id != $ns)] | length')" "0" + + # Filter by namespace FQN + run_otdfctl_scs list --namespace "$test_ns_fqn" --json + assert_success + assert_equal "$(echo "$output" | jq -r --arg id "$CREATED_ID" '.subject_condition_sets[] | select(.id == $id) | .id')" "$CREATED_ID" + # Ensure only SCS from the filtered namespace are returned + assert_equal "$(echo "$output" | jq -r --arg ns "$test_ns_id" '[.subject_condition_sets[] | select(.namespace.id != $ns)] | length')" "0" + + ./otdfctl $HOST $WITH_CREDS policy attributes namespaces unsafe delete --force --id "$test_ns_id" +} + +@test "Prune SCS - deletes unmapped SCS alone" { + echo -n "$SCS_1" > scs.json + + UNMAPPED_ID=$(./otdfctl policy scs create --subject-sets-file-json scs.json $HOST $WITH_CREDS --json | jq -r '.id') + MAPPED_ID=$(./otdfctl policy scs create --subject-sets "$SCS_2" $HOST $WITH_CREDS --json | jq -r '.id') + + # create a namespace, definition, value, sm to the value with the MAPPED_ID SCS + NS_ID=$(./otdfctl policy attributes namespaces create -n 'scs.net' $HOST $WITH_CREDS --json | jq -r '.id') + ATTR_ID=$(./otdfctl policy attributes create -n 'my_attr' --namespace "$NS_ID" -r "ANY_OF" $HOST $WITH_CREDS --json | jq -r '.id') + VAL_ID=$(./otdfctl policy attributes values create -v 'my_value' -a "$ATTR_ID" $HOST $WITH_CREDS --json | jq -r '.id') + + run ./otdfctl policy sm create --action 'delete' -a "$VAL_ID" --subject-condition-set-id "$MAPPED_ID" $HOST $WITH_CREDS + assert_success + + run_otdfctl_scs prune --force + assert_success + assert_output --partial "$UNMAPPED_ID" + refute_output --partial "$MAPPED_ID" +} diff --git a/otdfctl/e2e/subject-mapping.bats b/otdfctl/e2e/subject-mapping.bats new file mode 100755 index 0000000000..9f94ae0312 --- /dev/null +++ b/otdfctl/e2e/subject-mapping.bats @@ -0,0 +1,234 @@ +#!/usr/bin/env bats + +# Tests for subject mappings + +setup_file() { + export WITH_CREDS='--with-client-creds-file ./creds.json' + export HOST='--host http://localhost:8080' + + # Create two namespaced values to be used in other tests + export NS_NAME="subject-mappings-test.net" + export NS_FQN="https://$NS_NAME" + export NS_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes namespaces create -n "$NS_NAME" --json | jq -r '.id') + ATTR_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes create --namespace "$NS_ID" --name attr1 --rule ANY_OF --json | jq -r '.id') + # Names prefixed with SM to avoid conflicts across tests when running in parallel + export SM_VAL1_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes values create --attribute-id "$ATTR_ID" --value val1 --json | jq -r '.id') + export SM_VAL2_ID=$(./otdfctl $HOST $WITH_CREDS policy attributes values create --attribute-id "$ATTR_ID" --value value2 --json | jq -r '.id') + + export SCS_1='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["ShinyThing"],"subject_external_selector_value":".team.name"},{"operator":2,"subject_external_values":["marketing"],"subject_external_selector_value":".org.name"}],"boolean_operator":1}]}]' + export SCS_2='[{"condition_groups":[{"conditions":[{"operator":2,"subject_external_values":["CoolTool","RadService"],"subject_external_selector_value":".team.name"},{"operator":1,"subject_external_values":["sales"],"subject_external_selector_value":".org.name"}],"boolean_operator":2}]}]' + + export ACTION_READ_NAME='read' + export ACTION_READ_ID=$(./otdfctl $HOST $WITH_CREDS policy actions get --name "$ACTION_READ_NAME" --json | jq -r '.id') + export ACTION_CREATE_NAME='create' + export ACTION_CREATE_ID=$(./otdfctl $HOST $WITH_CREDS policy actions get --name "$ACTION_CREATE_NAME" --json | jq -r '.id') +} + +setup() { + load "${BATS_LIB_PATH}/bats-support/load.bash" + load "${BATS_LIB_PATH}/bats-assert/load.bash" + + # invoke binary with credentials + run_otdfctl_sm () { + run sh -c "./otdfctl $HOST $WITH_CREDS policy subject-mappings $*" + } + +} + +teardown_file() { + # remove the created namespace with all underneath upon test suite completion + ./otdfctl $HOST $WITH_CREDS policy attributes namespaces unsafe delete --force --id "$NS_ID" + + unset HOST WITH_CREDS SM_VAL1_ID SM_VAL2_ID NS_ID NS_FQN NS_NAME SCS_1 SCS_2 +} + +@test "Create subject mapping" { + # create with simultaneous new SCS + run ./otdfctl $HOST $WITH_CREDS policy subject-mappings create -a "$SM_VAL1_ID" --action "$ACTION_CREATE_NAME" --action "$ACTION_READ_NAME" --subject-condition-set-new "$SCS_2" + assert_success + assert_output --partial "Namespace" + assert_output --partial "Subject Condition Set: Id" + assert_output --partial ".team.name" + assert_line --regexp "Attribute Value Id.*$SM_VAL1_ID" + + # scs is required + run_otdfctl_sm create --attribute-value-id "$SM_VAL2_ID" --action "$ACTION_CREATE_NAME" + assert_failure + assert_output --partial "At least one Subject Condition Set flag [--subject-condition-set-id, --subject-condition-set-new] must be provided" + + # action is required + run_otdfctl_sm create -a "$SM_VAL1_ID" --subject-condition-set-new "$SCS_2" + assert_failure + assert_output --partial "At least one Action [--action] is required" +} + +@test "Match subject mapping" { + # create with simultaneous new SCS + NEW_SCS='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["sales"],"subject_external_selector_value":".department"}],"boolean_operator":2}]}]' + NEW_SM_ID=$(./otdfctl $HOST $WITH_CREDS policy subject-mappings create -a "$SM_VAL2_ID" --action "$ACTION_READ_NAME" --subject-condition-set-new "$NEW_SCS" --json | jq -r '.id') + + run_otdfctl_sm match -x '.department' + assert_success + assert_output --partial "$NEW_SM_ID" + + matched_subject='{"department":"any_department"}' + run ./otdfctl policy sm match --subject "$matched_subject" $HOST $WITH_CREDS + assert_success + assert_output --partial "$NEW_SM_ID" + + # JWT includes 'department' in token claims + run_otdfctl_sm match -s 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkZXBhcnRtZW50Ijoibm93aGVyZV9zcGVjaWFsIn0.784uXYtfOv4tdM6JRgBMua4bBNDjUGbcr89QQKzCXfU' + assert_success + assert_output --partial "$NEW_SM_ID" + + run_otdfctl_sm match --selector '.not_found' + assert_success + refute_output --partial "$NEW_SM_ID" + + unmatched_subject='{"dept":"nope"}' + run ./otdfctl policy sm match -s "$unmatched_subject" $HOST $WITH_CREDS + assert_success + refute_output --partial "$NEW_SM_ID" + + # JWT lacks 'department' in token claims + run_otdfctl_sm match -s 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhYmMiOiJub3doZXJlX3NwZWNpYWwifQ.H39TXi1gYWRhXIRkfxFJwrZz42eE4y8V5BQX-mg8JAo' + assert_success + refute_output --partial "$NEW_SM_ID" +} + +@test "Get subject mapping" { + run ./otdfctl $HOST $WITH_CREDS policy sm create -a "$SM_VAL2_ID" --action "custom_sm_action_test" --subject-condition-set-new "$SCS_1" --json + assert_success + created=$(echo "$output" | jq -r '.id') + scs_1_id=$(echo "$output" | jq -r '.subject_condition_set.id') + assert_not_equal "$created" "null" + assert_not_equal "$created" "" + assert_not_equal "$scs_1_id" "null" + assert_not_equal "$scs_1_id" "" + + # table + run_otdfctl_sm get --id "$created" + assert_success + assert_line --regexp "Id.*$created" + assert_output --partial "Namespace" + assert_line --regexp "Attribute Value: Id.*$SM_VAL2_ID" + assert_line --regexp "Attribute Value: Value.*value2" + assert_line --regexp "Subject Condition Set: Id.*$scs_1_id" + + # json + run_otdfctl_sm get --id "$created" --json + assert_success + [ "$(echo $output | jq -r '.id')" = "$created" ] + [ "$(echo $output | jq -r '.attribute_value.id')" = "$SM_VAL2_ID" ] + [ "$(echo $output | jq -r '.subject_condition_set.id')" = "$scs_1_id" ] + [ "$(echo $output | jq -r '.actions[0].name')" = "custom_sm_action_test" ] +} + +@test "Update a subject mapping" { + skip "Temporarily disabled [namespaced-actions]: expected action ID assertion is failing in CI" + run ./otdfctl $HOST $WITH_CREDS policy sm create -a "$SM_VAL1_ID" --action "$ACTION_READ_NAME" --subject-condition-set-new "$SCS_2" --json + assert_success + scs_to_update_with_id=$(echo "$output" | jq -r '.subject_condition_set.id') + assert_not_equal "$scs_to_update_with_id" "null" + assert_not_equal "$scs_to_update_with_id" "" + + run ./otdfctl $HOST $WITH_CREDS policy sm create -a "$SM_VAL1_ID" --action "$ACTION_READ_NAME" --subject-condition-set-new "$SCS_1" --json + assert_success + created=$(echo "$output" | jq -r '.id') + assert_not_equal "$created" "null" + assert_not_equal "$created" "" + + # replace the action (always destructive replacement) + run_otdfctl_sm update --id "$created" --action "$ACTION_CREATE_NAME" --json + assert_success + [ "$(echo $output | jq -r '.id')" = "$created" ] + [ "$(echo $output | jq -r '.actions[0].name')" = "$ACTION_CREATE_NAME" ] + [ "$(echo $output | jq -r '.actions[0].id')" = "$ACTION_CREATE_ID" ] + + # reassign the SCS being mapped to + run_otdfctl_sm update --id "$created" --subject-condition-set-id "$scs_to_update_with_id" --json + assert_success + assert_equal "$(echo $output | jq -r '.id')" "$created" + assert_equal "$(echo $output | jq -r '.subject_condition_set.id')" "$scs_to_update_with_id" +} + +@test "List subject mappings" { + created=$(./otdfctl $HOST $WITH_CREDS policy sm create -a "$SM_VAL1_ID" --action "$ACTION_CREATE_NAME" --subject-condition-set-new "$SCS_2" --json | jq -r '.id') + + run_otdfctl_sm list + assert_success + assert_output --partial "$created" + assert_output --partial "Namespace" + assert_output --partial "Total" + assert_line --regexp "Current Offset.*0" + + run_otdfctl_sm list --json + assert_success + assert_equal "$(echo "$output" | jq -r --arg id "$created" '.subject_mappings[] | select(.id == $id) | .attribute_value.fqn')" "$NS_FQN/attr/attr1/value/val1" + assert_not_equal $(echo "$output" | jq -r 'pagination') "null" + total=$(echo "$output" | jq -r '.pagination.total') + [[ "$total" -ge 1 ]] +} + +@test "Create subject mapping with namespace ID" { + run ./otdfctl $HOST $WITH_CREDS policy subject-mappings create -a "$SM_VAL2_ID" --action "$ACTION_READ_NAME" --subject-condition-set-new "$SCS_2" --namespace "$NS_ID" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.namespace.id')" "$NS_ID" + assert_equal "$(echo "$output" | jq -r '.attribute_value.id')" "$SM_VAL2_ID" + assert_not_equal "$(echo "$output" | jq -r '.subject_condition_set.id')" "null" + assert_equal "$(echo "$output" | jq -r '.. | .subject_external_selector_value? // empty' | head -n 1)" ".team.name" + created=$(echo "$output" | jq -r '.id') + run_otdfctl_sm delete --id "$created" --force + assert_success +} + +@test "Create subject mapping with namespace FQN" { + run ./otdfctl $HOST $WITH_CREDS policy subject-mappings create -a "$SM_VAL2_ID" --action "$ACTION_READ_NAME" --subject-condition-set-new "$SCS_2" --namespace "$NS_FQN" --json + assert_success + assert_equal "$(echo "$output" | jq -r '.namespace.id')" "$NS_ID" + assert_equal "$(echo "$output" | jq -r '.attribute_value.id')" "$SM_VAL2_ID" + assert_not_equal "$(echo "$output" | jq -r '.subject_condition_set.id')" "null" + assert_output --partial ".team.name" + created=$(echo "$output" | jq -r '.id') + + run_otdfctl_sm delete --id "$created" --force + assert_success +} + +@test "List subject mappings with namespace" { + test_ns_name="subject-mappings-list-$BATS_TEST_NUMBER.net" + test_ns_fqn="https://$test_ns_name" + test_ns_id=$(./otdfctl $HOST $WITH_CREDS policy attributes namespaces create -n "$test_ns_name" --json | jq -r '.id') + test_attr_id=$(./otdfctl $HOST $WITH_CREDS policy attributes create --namespace "$test_ns_id" --name attr-list --rule ANY_OF --json | jq -r '.id') + test_val_id=$(./otdfctl $HOST $WITH_CREDS policy attributes values create --attribute-id "$test_attr_id" --value val-list --json | jq -r '.id') + created=$(./otdfctl $HOST $WITH_CREDS policy sm create -a "$test_val_id" --action "$ACTION_CREATE_NAME" --subject-condition-set-new "$SCS_2" --namespace "$test_ns_id" --json | jq -r '.id') + + run_otdfctl_sm list --namespace "$test_ns_id" + assert_success + assert_output --partial "$created" + assert_output --partial "Total" + + run_otdfctl_sm list --namespace "$test_ns_id" --json + assert_success + assert_equal "$(echo "$output" | jq -r --arg id "$created" '.subject_mappings[] | select(.id == $id) | .id')" "$created" + # Ensure only subject mappings from the filtered namespace are returned + assert_equal "$(echo "$output" | jq -r --arg ns "$test_ns_id" '[.subject_mappings[] | select(.namespace.id != $ns)] | length')" "0" + + # Filter by namespace fqn + run_otdfctl_sm list --namespace "$test_ns_fqn" --json + assert_success + assert_equal "$(echo "$output" | jq -r --arg id "$created" '.subject_mappings[] | select(.id == $id) | .id')" "$created" + # Ensure only subject mappings from the filtered namespace are returned + assert_equal "$(echo "$output" | jq -r --arg ns "$test_ns_id" '[.subject_mappings[] | select(.namespace.id != $ns)] | length')" "0" + + ./otdfctl $HOST $WITH_CREDS policy attributes namespaces unsafe delete --force --id "$test_ns_id" +} + +@test "Delete subject mapping" { + # Create a subject mapping specifically for deletion to avoid race conditions in parallel test execution + to_delete=$(./otdfctl $HOST $WITH_CREDS policy sm create -a "$SM_VAL1_ID" --action "$ACTION_READ_NAME" --subject-condition-set-new "$SCS_1" --json | jq -r '.id') + # --force to avoid indefinite hang waiting for confirmation + run_otdfctl_sm delete --id "$to_delete" --force + assert_success + assert_line --regexp "Id.*$to_delete" +} diff --git a/otdfctl/e2e/teardown_suite.bash b/otdfctl/e2e/teardown_suite.bash new file mode 100755 index 0000000000..2cae5e7e19 --- /dev/null +++ b/otdfctl/e2e/teardown_suite.bash @@ -0,0 +1,9 @@ +#!/bin/bash + +#### +# Remove the creds file if it exists +#### + +setup_suite(){ + rm -f ./creds.json +} \ No newline at end of file diff --git a/otdfctl/e2e/testrail-integration/samples-for-virtru-instance/testname-to-testrail-id.virtru.json b/otdfctl/e2e/testrail-integration/samples-for-virtru-instance/testname-to-testrail-id.virtru.json new file mode 100644 index 0000000000..60c0402b33 --- /dev/null +++ b/otdfctl/e2e/testrail-integration/samples-for-virtru-instance/testname-to-testrail-id.virtru.json @@ -0,0 +1,308 @@ +{ + "Actions": { + "Create a new custom action - Good": "C871535", + "Create a new action - Bad": "C871536", + "Get an action - Good": "C871537", + "Get an action - Bad": "C871538", + "List actions": "C871539", + "Update action": "C871540", + "Delete action - bad": "C871541", + "Delete action - good": "C871542" + }, + "Attributes": { + "Create an attribute - with values": "C793409", + "Create an attribute - Allow Traversal": "C875790", + "Create an attribute - bad": "C793410", + "Get an attribute definition - good": "C793412", + "Get an attribute definition - bad": "C793413", + "Update an attribute definition (safe) - good": "C793414", + "Update an attribute definition (safe) - bad": "C793415", + "List attribute definitions": "C793416", + "List - comprehensive pagination tests": "C871543", + "Deactivate then unsafe reactivate an attribute definition": "C793417", + "Unsafe update an attribute definition": "C793418", + "Unsafe Update preserves allow traversal when unchanged": "C875791", + "Unsafe Update can disallow traversal": "C875792", + "Assign/Remove KAS key from attribute definition - With Attribute Id": "C874449", + "Assign/Remove KAS key from attribute definition - With Attribute FQN": "C874450", + "Assign/Remove KAS key from attribute value - With Value Id": "C874451", + "Assign/Remove KAS key from attribute value - With Value FQN": "C874452", + "KAS key assignment error handling - attribute": "C874453", + "KAS key assignment error handling - attribute value": "C874454", + "List attribute values - Good": "C875684", + "List attribute values - Bad": "C875685" + }, + "Auth": { + "helpful error if wrong platform endpoint host": "C793419", + "helpful error if bad credentials": "C793420", + "helpful error if missing client credentials": "C793421", + "helpful error if missing host": "C793422" + }, + "Encrypt/Decrypt": { + "roundtrip TDF3, no attributes, file": "C793423", + "roundtrip TDF3, no attributes, ec-wrapping, file": "C797126", + "roundtrip TDF3, one attribute, stdin": "C793424", + "roundtrip TDF3, one attribute, mixed case FQN, stdin": "C793425", + "allow traversal with mapped key uses definition when value missing": "C875793", + "allow traversal uses attribute value mapping when value present": "C875794", + "allow traversal with inactive attribute value fails": "C875795", + "roundtrip TDF3, assertions, stdin": "C797127", + "roundtrip TDF3, assertions with HS256 keys and verification, file": "C797128", + "roundtrip TDF3, assertions with RS256 keys and verification, file": "C797129", + "roundtrip TDF3, with target version < 4.3.0": "C871544", + "roundtrip TDF3, with target version >= 4.3.0": "C871545", + "roundtrip TDF3, with allowlist containing platform kas": "C871546", + "roundtrip TDF3, with allowlist containing non existent kas (should fail)": "C871547", + "roundtrip TDF3, ignoring allowlist": "C871548", + "roundtrip TDF3, not entitled to data, no required obligations returned": "C875374", + "roundtrip TDF3, entitled to data, required obligations returned": "C875375" + }, + "KAS Grants": { + "Unassign rejects more than one type of grant at once": "C793434", + "Assign grant prints warning": "C874455", + "Optional ID flag string error message": "C793435" + }, + "KAS Keys Mappings": { + "kas-keys-mappings: list key mappings for a specific key by kas id": "C874696", + "kas-keys-mappings: list key mappings for a specific key by kas name": "C874697", + "kas-keys-mappings: list key mappings for a specific key by kas uri": "C874698", + "kas-keys-mappings: list key mappings with pagination": "C874699", + "kas-keys-mappings: list key mappings - required together are missing": "C874700", + "kas-keys-mappings: list key mappings - mutually exclusive flags": "C874701" + }, + "KAS Keys": { + "kas-keys: create key (local mode, rsa:2048)": "C874456", + "kas-keys: create key (local mode, ec:secp256r1)": "C874457", + "kas-keys: create key (public_key mode)": "C874458", + "kas-keys: create key (remote mode)": "C874459", + "kas-keys: create key (provider mode)": "C874460", + "kas-keys: create key with labels": "C874461", + "kas-keys: create key (missing key-id)": "C874462", + "kas-keys: create key (missing algorithm)": "C874463", + "kas-keys: create key (missing mode)": "C874464", + "kas-keys: create key (local mode, missing wrapping-key-id)": "C874465", + "kas-keys: create key (local mode, missing wrapping-key)": "C874466", + "kas-keys: create key (public_key mode, missing pem)": "C874467", + "kas-keys: create key (remote mode, missing pem)": "C874468", + "kas-keys: create key (remote mode, missing provider-config-id)": "C874469", + "kas-keys: create key (remote mode, missing wrapping-key-id)": "C874470", + "kas-keys: create key (provider mode, missing wrapping-key-id)": "C874471", + "kas-keys: create key (provider mode, missing provider-config-id)": "C874472", + "kas-keys: create key (remote mode, pem not base64)": "C874473", + "kas-keys: create key (public_key mode, pem not base64)": "C874474", + "kas-keys: create key (public_key mode, invalid PEM content)": "C875376", + "kas-keys: create key (public_key mode, EC key with RSA algorithm)": "C875377", + "kas-keys: create key (missing kas identifier)": "C874475", + "kas-keys: create key (using kasName)": "C874476", + "kas-keys: create key (using kasUri)": "C874477", + "kas-keys: create key (invalid algorithm value)": "C874478", + "kas-keys: create key (invalid mode value)": "C874479", + "kas-keys: create key (duplicate key-id)": "C874480", + "kas-keys: create key (invalid kas identifier)": "C874481", + "kas-keys: create key (invalid hex encoded wrapping-key)": "C874482", + "kas-keys: get key by system ID": "C874483", + "kas-keys: get key by user key-id and kasId": "C874484", + "kas-keys: get key by user key-id and kasName": "C874485", + "kas-keys: get key by user key-id and kasUri": "C874486", + "kas-keys: get key (failure: only key-id, missing KAS identifier)": "C874487", + "kas-keys: get key (failure: only kas, missing key-id or system id)": "C874488", + "kas-keys: get key (not found by system ID)": "C874489", + "kas-keys: get key (not found by user key-id and kas)": "C874490", + "kas-keys: update key labels (add)": "C874491", + "kas-keys: update key labels (replace)": "C874492", + "kas-keys: update key (not found)": "C874493", + "kas-keys: update key (missing id)": "C874494", + "kas-keys: list keys (default limit and offset)": "C874495", + "kas-keys: list keys (pagination with limit and offset)": "C874496", + "kas-keys: list keys (filter by algorithm rsa:2048)": "C874497", + "kas-keys: list keys (filter by kas)": "C874498", + "kas-keys: list keys (filter by kasName)": "C874499", + "kas-keys: list keys (filter by kasUri)": "C874500", + "kas-keys: list legacy keys": "C875225", + "kas-keys: list keys (invalid algorithm)": "C874501", + "kas-keys: list keys (legacy=invalid)": "C875226", + "kas-keys: rotate key": "C874502", + "kas-keys: rotate key (missing key)": "C874503", + "kas-keys: rotate key (missing key-id)": "C874504", + "kas-keys: rotate key (missing algorithm)": "C874505", + "kas-keys: rotate key (missing mode)": "C874506", + "kas-keys: rotate key (local mode, missing wrapping-key-id)": "C874507", + "kas-keys: rotate key (local mode, missing wrapping-key)": "C874508", + "kas-keys: rotate key (public_key mode, missing public-key-pem)": "C874509", + "kas-keys: rotate key (remote mode, missing provider-config-id)": "C874510", + "kas-keys: rotate key (invalid algorithm)": "C874511", + "kas-keys: rotate key (invalid mode)": "C874512", + "kas-keys: rotate key (invalid hex encoded wrapping-key)": "C874513", + "kas-keys: import key successful": "C874682", + "kas-keys: import key successful (legacy=true)": "C875227", + "kas-keys: import key successful (legacy=false)": "C875228", + "kas-keys: import key failure (legacy=invalid)": "C875229", + "kas-keys: import key failure - missing required private key": "C874683", + "kas-keys: import key failure - invalid wrapping key": "C874684", + "kas-keys: import key failure - invalid public key PEM": "C874685", + "kas-keys: import key failure - invalid private key PEM": "C874687", + "kas-keys: import key failure - invalid algorithm": "C874688", + "kas-keys: import key failure - missing wrapping key ID": "C874689", + "kas-keys: import key failure - missing wrapping key": "C874690", + "kas-keys: delete key": "C874691", + "kas-keys: delete key failure - (missing id)": "C874692", + "kas-keys: delete key failure - (missing key-id)": "C874693", + "kas-keys: delete key failure - (missing kas-uri)": "C874694" + }, + "KAS Registry": { + "create KAS registration with invalid URI - fails": "C797131", + "create KAS registration with duplicate URI - fails": "C797132", + "create KAS registration with duplicate name - fails": "C797133", + "create KAS registration with invalid name - fails": "C797134", + "update registered KAS": "C793438", + "update registered KAS with invalid URI - fails": "C797135", + "update registered KAS with invalid name - fails": "C797136", + "list registered KASes": "C793439" + }, + "Base Key": { + "base-key: get (initially no base key should be set for a new KAS)": "C874514", + "base-key: set by --key (uuid)": "C874515", + "base-key: set by --key(id) and --kas(id)": "C874516", + "base-key: get (after setting a base key)": "C874517", + "base-key: set by --key(id) and --kas(name)": "C874518", + "base-key: set by --key(id) and --kas(uri)": "C874519", + "base-key: set, get, and verify previous base key": "C874520", + "base-key: set (missing kas identifier)": "C874521", + "base-key: set (missing key identifier: id or keyId)": "C874522", + "base-key: set (using non-existent keyId)": "C874523", + "base-key: set (using non-existent kasId)": "C874524" + }, + "Logging": { + "version is logged to stderr when debug logging enabled": "C875686", + "version is logged to stderr when debug enabled": "C875687" + }, + "Namespaces": { + "Create a namespace - Good": "C793440", + "Create a namespace - Bad": "C793441", + "Get a namespace - Good": "C793442", + "Get a namespace - Bad": "C793443", + "List namespaces - when active": "C793444", + "Update namespace - Safe": "C793445", + "Update namespace - Unsafe": "C793446", + "Assign/Remove KAS key from namespace - With Namespace ID": "C874527", + "Assign/Remove KAS key from namespace - With Namespace FQN": "C874528", + "KAS key assignment error handling - namespace": "C874529", + "Deactivate namespace": "C793447", + "List namespaces - when inactive": "C793448", + "Unsafe reactivate namespace": "C793449", + "List namespaces - when reactivated": "C793450", + "Unsafe delete namespace": "C793451", + "List namespaces - when deleted": "C793452" + }, + "Obligations": { + "Create a obligation - Good": "C875288", + "Create a obligation - Bad": "C875289", + "Get an obligation - Good": "C875290", + "Get an obligation - Bad": "C875291", + "List obligations": "C875292", + "Update obligation": "C875293", + "Delete obligation - Good": "C875294", + "Delete obligation - Bad": "C875295", + "Create an obligation value - Good": "C875296", + "Create an obligation value - Bad": "C875297", + "Create an obligation value with triggers - JSON Array - Success": "C875298", + "Create an obligation value with triggers - JSON File - Success": "C875299", + "Create an obligation value with triggers - Bad": "C875300", + "Get an obligation value - Good": "C875301", + "Get an obligation value - Bad": "C875302", + "Update obligation values": "C875303", + "Update obligation values with triggers - Success": "C875304", + "Update obligation values with triggers - Bad": "C875305", + "Delete obligation value - Good": "C875306", + "Delete obligation value - Bad": "C875307", + "Create an obligation trigger - Required Only - IDs - Success": "C875308", + "Create an obligation trigger - Required Only - FQNs - Success": "C875309", + "Create an obligation trigger - Optional Fields - Success": "C875310", + "Create an obligation trigger - Bad": "C875311", + "Delete an obligation trigger - Good": "C875312", + "List obligation triggers - No filters": "C875378", + "List obligation triggers - Limit and Offset": "C875379", + "List obligation triggers - Filter by Namespace ID": "C875380", + "List obligation triggers - Filter by Namespace FQN": "C875381" + }, + "Profile": { + "profile create": "C793453", + "profile list shows profiles and default": "C793454", + "profile get shows profile details": "C793455", + "profile delete removes profile": "C793456", + "profile set-default updates default profile": "C793457", + "profile set-endpoint updates endpoint": "C793458", + "profile delete-all deletes all profiles": "C875688", + "profile migrate moves keyring profiles to filesystem": "C875689", + "profile keyring cleanup removes all keyring profiles": "C875690" + }, + "Provider Configuration": { + "fail to create provider configuration without config": "C874530", + "fail to create provider configuration without name": "C874531", + "fail to create provider configuration with invalid config": "C874532", + "create provider configuration": "C874533", + "get provider configuration by id": "C874534", + "get provider configuration by name": "C874535", + "fail to get provider configuration - no required flags": "C874536", + "fail to get provider configuration with non-existent name": "C874537", + "list provider configurations": "C874538", + "update provider configuration - success": "C874539", + "fail to update provider configuration - missing id": "C874540", + "fail to update provider configuration - no optional flags": "C874541", + "fail to update provider configuration - invalid config format": "C874542", + "delete provider configuration -- success": "C874543", + "delete provider configuration fail -- no id": "C874544", + "delete provider configuration fail -- no force": "C875223" + }, + "Registered Resources": { + "Create a registered resource - Good": "C874545", + "Create a registered resource - Bad": "C874546", + "Get a registered resource - Good": "C874547", + "Get a registered resource - Bad": "C874548", + "List registered resources": "C874549", + "Update registered resource": "C874550", + "Delete registered resource - Good": "C874551", + "Delete registered resource - Bad": "C874552", + "Create a registered resource value - Good": "C874553", + "Create a registered resource value - Bad": "C874554", + "Get a registered resource value - Good": "C874555", + "Get a registered resource value - Bad": "C874556", + "List registered resource values - Good": "C874557", + "List registered resource values - Bad": "C874558", + "Update registered resource values": "C874559", + "Delete registered resource value - Good": "C874560", + "Delete registered resource value - Bad": "C874561" + }, + "Resource Mapping Groups": { + "Create resource mapping group": "C874562", + "Get resource mapping group": "C874563", + "Update a resource mapping group": "C874564", + "List resource mapping groups": "C874565", + "Delete resource mapping group": "C874566" + }, + "Resource Mapping": { + "Create resource mapping": "C793459", + "Create resource mapping in a group": "C874567", + "Get resource mapping": "C793460", + "Update a resource mapping": "C793461", + "List resource mappings": "C793462", + "Delete resource mapping": "C793463" + }, + "Subject Condition Sets": { + "Create a Subject Condition Set (SCS) - from file": "C794011", + "Create a Subject Condition Set (SCS) - from flag value JSON": "C794012", + "Get a SCS": "C794013", + "Update a SCS - from flag value JSON": "C794014", + "Update a SCS - from file": "C797138", + "List SCS": "C794015", + "Prune SCS - deletes unmapped SCS alone": "C794016" + }, + "Subject Mapping": { + "Create subject mapping": "C793464", + "Match subject mapping": "C797137", + "Get subject mapping": "C793465", + "Update a subject mapping": "C793466", + "List subject mappings": "C793467", + "Delete subject mapping": "C793468" + } +} diff --git a/otdfctl/e2e/testrail-integration/samples-for-virtru-instance/testrail-virtru.config.json b/otdfctl/e2e/testrail-integration/samples-for-virtru-instance/testrail-virtru.config.json new file mode 100644 index 0000000000..0617cec05f --- /dev/null +++ b/otdfctl/e2e/testrail-integration/samples-for-virtru-instance/testrail-virtru.config.json @@ -0,0 +1,7 @@ +{ + "url": "https://virtru.testrail.net", + "projectId": 63, + "tapFile": "./bats-results.tap", + "suiteId": 1, + "milestoneId": 1 +} diff --git a/otdfctl/e2e/testrail-integration/testname-to-testrail-id.example.json b/otdfctl/e2e/testrail-integration/testname-to-testrail-id.example.json new file mode 100644 index 0000000000..08373aad8c --- /dev/null +++ b/otdfctl/e2e/testrail-integration/testname-to-testrail-id.example.json @@ -0,0 +1,9 @@ +{ + "section_1": { + "test_name_1": "C12345", + "test_name_2": "C67890" + }, + "section_2": { + "test_name_3": "C54321" + } +} \ No newline at end of file diff --git a/otdfctl/e2e/testrail-integration/testrail.config.example.json b/otdfctl/e2e/testrail-integration/testrail.config.example.json new file mode 100644 index 0000000000..ce7d6a9c95 --- /dev/null +++ b/otdfctl/e2e/testrail-integration/testrail.config.example.json @@ -0,0 +1,5 @@ +{ + "url": "https://yourcompany.testrail.net", + "projectId": 123, + "tapFile": "./bats-results.tap", +} diff --git a/otdfctl/e2e/testrail-integration/upload-bats-test-results-to-testrail.sh b/otdfctl/e2e/testrail-integration/upload-bats-test-results-to-testrail.sh new file mode 100755 index 0000000000..dba2a38142 --- /dev/null +++ b/otdfctl/e2e/testrail-integration/upload-bats-test-results-to-testrail.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ================================================================ +# TestRail Integration Script for BATS TAP results +# +# This script: +# 1. Reads TestRail config from `testrail.config.json` +# 2. Reads mapping file `testname-to-testrail-id.json` (test name → case ID) +# 3. Parses BATS TAP results from `bats-results.tap` +# 4. Creates or finds a TestRail run by name +# 5. Uploads results for matched cases +# 6. Writes a local mapping report file (mapping-report.json) +# +# Dependencies: jq, curl +# ================================================================ + +# ----------------------------- +# Load TestRail config +# ----------------------------- + +CONFIG_FILE="$(dirname "$0")/testrail.config.json" + +if [[ ! -f "$CONFIG_FILE" ]]; then + echo "❌ Missing $CONFIG_FILE. Copy testrail.config.example.json and update values." + exit 1 +fi + +TESTRAIL_URL=$(jq -r '.url' "$CONFIG_FILE") +PROJECT_ID=$(jq -r '.projectId' "$CONFIG_FILE") +TAP_FILE=$(jq -r '.tapFile' "$CONFIG_FILE") + +# ----------------------------- +# Mapping config and report file +# ----------------------------- +MAPPING_FILE="$(dirname "$0")/testname-to-testrail-id.json" +REPORT_FILE="mapping-report.txt" + +if [ ! -f "$MAPPING_FILE" ]; then + echo "❌ Missing $MAPPING_FILE. Copy testname-to-testrail-id.example.json and update with your case IDs." + exit 1 +fi + +# ----------------------------- +# Load TestRails credentials from env +# ----------------------------- +TESTRAIL_USER=${TESTRAIL_USER:-""} +TESTRAIL_PASS=${TESTRAIL_PASS:-""} + +if [[ -z "$TESTRAIL_USER" || -z "$TESTRAIL_PASS" ]]; then + echo "❌ Missing TestRail credentials. Please set TESTRAIL_USER and TESTRAIL_PASS env vars." + exit 1 +fi + +# ----------------------------- +# Run name (env override or auto-generate) +# ----------------------------- +RUN_NAME=${TESTRAIL_CLI_RUN_NAME:-"Otdfctl CLI auto tests - $(date -Iseconds)"} + +# ----------------------------- +# Functions +# ----------------------------- + +# ---- Lookup TestRail case ID in JSON mapping file by test name (case-insensitive names comparison to avoid accident failures) ---- +lookup_case_id() { + local name="$1" + local lowercasename + lowercasename=$(echo "$name" | tr '[:upper:]' '[:lower:]') + + # Detect whether JSON is nested (values are objects) or flat + if jq -e 'map_values(type) | .[] | select(.=="object")' "$MAPPING_FILE" >/dev/null 2>&1; then + # Nested JSON: preserve spaces in section names (allow multi words) + while IFS= read -r section; do + id=$(jq -r --arg n "$lowercasename" --arg s "$section" ' + reduce ( .[$s] | to_entries[] ) as $item (null; + if ($item.key | ascii_downcase) == $n then $item.value else . end + ) + ' "$MAPPING_FILE") + + if [[ -n "$id" && "$id" != "null" ]]; then + echo "$id|$section" + return 0 + fi + done < <(jq -r 'keys[]' "$MAPPING_FILE") + else + # Flat JSON + id=$(jq -r --arg n "$lowercasename" ' + reduce to_entries[] as $item (null; + if ($item.key | ascii_downcase) == $n then $item.value else . end + ) + ' "$MAPPING_FILE") + + if [[ -n "$id" && "$id" != "null" ]]; then + echo "$id|" + return 0 + fi + fi + + echo "" + return 1 +} + +# Parse TAP report and build results to push + generate mapping report to identify gaps more easily +parse_tap() { + # Check if TAP file exists + if [[ ! -f "$TAP_FILE" ]]; then + echo "❌ TAP file not found: $TAP_FILE" + exit 1 + fi + + : > "$REPORT_FILE" # truncate/clear old report + + while IFS= read -r line; do + if [[ "$line" =~ ^(ok|not\ ok)\ ([0-9]+)\ (.*) ]]; then + status="${BASH_REMATCH[1]}" + name="${BASH_REMATCH[3]}" + + # Detect and handle skip + if [[ "$line" =~ \#\ skip ]]; then + status_id=2 # Skipped + name=$(echo "$name" | sed -E 's/ +# skip.*//') # remove trailing " # skip ..." + elif [[ "$status" == "ok" ]]; then + status_id=1 # Passed + else + status_id=5 # Failed + fi + + mapping=$(lookup_case_id "$name" || true) + if [[ -n "$mapping" ]]; then + case_id="${mapping%%|*}" + section="${mapping##*|}" + echo "\"$name\" YES $case_id (Section: $section)" + echo "\"$name\" YES $case_id" >> "$REPORT_FILE" + results+=("{\"case_id\": ${case_id#C}, \"status_id\": $status_id, \"comment\": \"$name\"}") + else + echo "\"$name\" NO" + echo "\"$name\" NO" >> "$REPORT_FILE" + fi + fi + done < "$TAP_FILE" +} + +find_existing_run() { + curl -s -u "$TESTRAIL_USER:$TESTRAIL_PASS" \ + "$TESTRAIL_URL/index.php?/api/v2/get_runs/$PROJECT_ID" | + jq ".runs[] | select(.name==\"$RUN_NAME\") | .id" | head -n1 +} + +create_run() { + local case_ids_json + case_ids_json=$(printf '%s\n' "${results[@]}" | jq -s '.[].case_id' | jq -s .) + + curl -s -u "$TESTRAIL_USER:$TESTRAIL_PASS" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"$RUN_NAME\", \"include_all\": false, \"case_ids\": $case_ids_json}" \ + "$TESTRAIL_URL/index.php?/api/v2/add_run/$PROJECT_ID" | jq .id +} + +push_results() { + local run_id="$1" + local results_json + results_json=$(printf '%s\n' "${results[@]}" | jq -s .) + + curl -s -u "$TESTRAIL_USER:$TESTRAIL_PASS" \ + -H "Content-Type: application/json" \ + -d "{\"results\": $results_json}" \ + "$TESTRAIL_URL/index.php?/api/v2/add_results_for_cases/$run_id" > /dev/null +} + +# ----------------------------- +# Main +# ----------------------------- +declare -a results=() + +parse_tap + +run_id=$(find_existing_run) +if [[ -z "$run_id" ]]; then + echo "ℹ️ No existing run found, creating new one..." + run_id=$(create_run) + echo "✅ Created new run ID: $run_id" +else + echo "ℹ️ Found existing run ID: $run_id" +fi + +push_results "$run_id" +echo "✅ Results uploaded to TestRail run $run_id" +echo "📄 Mapping report written to $REPORT_FILE" diff --git a/otdfctl/go.mod b/otdfctl/go.mod new file mode 100644 index 0000000000..ec6ea5471e --- /dev/null +++ b/otdfctl/go.mod @@ -0,0 +1,114 @@ +module github.com/opentdf/platform/otdfctl + +go 1.25.0 + +toolchain go1.25.8 + +require ( + github.com/Masterminds/semver/v3 v3.4.0 + github.com/adrg/frontmatter v0.2.0 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/glamour v0.10.0 + github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/evertras/bubble-table v0.19.2 + github.com/gabriel-vasile/mimetype v1.4.13 + github.com/go-jose/go-jose/v3 v3.0.5 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.0 + github.com/jrschumacher/go-osprofiles v0.0.0-20251201220924-3d077c5481e5 + github.com/opentdf/platform/lib/flattening v0.1.3 + github.com/opentdf/platform/lib/identifier v0.3.0 + github.com/opentdf/platform/lib/ocrypto v0.10.0 + github.com/opentdf/platform/protocol/go v0.21.0 + github.com/opentdf/platform/sdk v0.15.0 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 + github.com/zitadel/oidc/v3 v3.45.1 + golang.org/x/oauth2 v0.35.0 + golang.org/x/term v0.40.0 + google.golang.org/grpc v1.79.3 + google.golang.org/protobuf v1.36.10 +) + +require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250613105001-9f2d3c737feb.1 // indirect + connectrpc.com/connect v1.19.1 // indirect + github.com/BurntSushi/toml v0.3.1 // indirect + github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gowebpki/jcs v1.0.1 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect + github.com/zalando/go-keyring v0.2.6 // indirect + github.com/zitadel/logging v0.6.2 // indirect + github.com/zitadel/schema v1.3.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/otdfctl/go.sum b/otdfctl/go.sum new file mode 100644 index 0000000000..deb9284034 --- /dev/null +++ b/otdfctl/go.sum @@ -0,0 +1,326 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250613105001-9f2d3c737feb.1 h1:AUL6VF5YWL01j/1H/DQbPUSDkEwYqwVCNw7yhbpOxSQ= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250613105001-9f2d3c737feb.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/adrg/frontmatter v0.2.0 h1:/DgnNe82o03riBd1S+ZDjd43wAmC6W35q67NHeLkPd4= +github.com/adrg/frontmatter v0.2.0/go.mod h1:93rQCj3z3ZlwyxxpQioRKC1wDLto4aXHrbqIsnH9wmE= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/evertras/bubble-table v0.19.2 h1:u77oiM6JlRR+CvS5FZc3Hz+J6iEsvEDcR5kO8OFb1Yw= +github.com/evertras/bubble-table v0.19.2/go.mod h1:ifHujS1YxwnYSOgcR2+m3GnJ84f7CVU/4kUOxUCjEbQ= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= +github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gowebpki/jcs v1.0.1 h1:Qjzg8EOkrOTuWP7DqQ1FbYtcpEbeTzUoTN9bptp8FOU= +github.com/gowebpki/jcs v1.0.1/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.0 h1:+epNPbD5EqgpEMm5wrl4Hqts3jZt8+kYaqUisuuIGTk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.0/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA= +github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= +github.com/jrschumacher/go-osprofiles v0.0.0-20251201220924-3d077c5481e5 h1:NRqBTDlgz/9hwlLPQlkfoIOJE0leWwCO9PwMrSAiY34= +github.com/jrschumacher/go-osprofiles v0.0.0-20251201220924-3d077c5481e5/go.mod h1:xQeFVn7ra/QR1KbTZ3KApzUeicH1IFkJx1kr9dG8uOI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= +github.com/opentdf/platform/lib/flattening v0.1.3 h1:IuOm/wJVXNrzOV676Ticgr0wyBkL+lVjsoSfh+WSkNo= +github.com/opentdf/platform/lib/flattening v0.1.3/go.mod h1:Gs/T+6FGZKk9OAdz2Jf1R8CTGeNRYrq1lZGDeYT3hrY= +github.com/opentdf/platform/lib/identifier v0.3.0 h1:kLVRVC8wE0kmuEIdodEhqIRym5OzbgpaGqg1CE09kA8= +github.com/opentdf/platform/lib/identifier v0.3.0/go.mod h1:3RlXYY9sxH/VnMkgm/M6zryUrtBL0lnsOVyqb0hYYvs= +github.com/opentdf/platform/lib/ocrypto v0.10.0 h1:7dn/z/1qH3p+gWCrfOoU7hj9XF/p5N+b2JBJuWF9aK0= +github.com/opentdf/platform/lib/ocrypto v0.10.0/go.mod h1:WASkoHreqgTFImB/gJW42VTdpi9AkgkmaW19y/fU+Ew= +github.com/opentdf/platform/protocol/go v0.21.0 h1:OZo/GqdIDQMbICx0tVdFHaFjl8CpILTIt8dS9bLYSRI= +github.com/opentdf/platform/protocol/go v0.21.0/go.mod h1:ufSzLVpcGv368L++kni7fzbN4ugtxmstcifmwI/o4T0= +github.com/opentdf/platform/sdk v0.15.0 h1:TAZJzC4RDlHLuMN562OhoVGykKxyEp+C1c5o9F31080= +github.com/opentdf/platform/sdk v0.15.0/go.mod h1:0u/1tDFkt2eL9W/2DzbHIF0ziel5F7Sn/OJp5EkbeRg= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= +github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU= +github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4= +github.com/zitadel/oidc/v3 v3.45.1 h1:x7J8NywTUtLR9T5uu2dufae3gJrl6VVpIfvGZy+kzJg= +github.com/zitadel/oidc/v3 v3.45.1/go.mod h1:oFArtAPTXEA4ajkIe/JfBjv7hhlD0kr///UqaO3Uzd0= +github.com/zitadel/schema v1.3.1 h1:QT3kwiRIRXXLVAs6gCK/u044WmUVh6IlbLXUsn6yRQU= +github.com/zitadel/schema v1.3.1/go.mod h1:071u7D2LQacy1HAN+YnMd/mx1qVE2isb0Mjeqg46xnU= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/otdfctl/main.go b/otdfctl/main.go new file mode 100644 index 0000000000..36cd86510f --- /dev/null +++ b/otdfctl/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "log/slog" + "os" + + "github.com/opentdf/platform/otdfctl/cmd" + "github.com/spf13/cobra" +) + +func main() { + // f, err := os.Create("cpu.pprof") + // if err != nil { + // panic(err) + // } + // pprof.StartCPUProfile(f) + // defer pprof.StopCPUProfile() + + l := new(slog.LevelVar) + l.Set(slog.LevelInfo) + l.UnmarshalText([]byte(os.Getenv("LOG_LEVEL"))) //nolint:errcheck // ignore error, just use default level + logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: l, + })) + + slog.SetDefault(logger) + + cobra.EnableTraverseRunHooks = true + cmd.Execute() +} diff --git a/otdfctl/migrations/artifact/artifact.go b/otdfctl/migrations/artifact/artifact.go new file mode 100644 index 0000000000..95b6af0bbe --- /dev/null +++ b/otdfctl/migrations/artifact/artifact.go @@ -0,0 +1,54 @@ +package artifact + +import ( + "errors" + "fmt" + "io" + + "github.com/Masterminds/semver/v3" + metadata "github.com/opentdf/platform/otdfctl/migrations/artifact/metadata" + artifactv1 "github.com/opentdf/platform/otdfctl/migrations/artifact/v1" +) + +const CurrentSchemaVersion = artifactv1.SchemaVersion + +var ( + currentSchemaVersion = semver.MustParse(CurrentSchemaVersion) + ErrUnsupportedSchemaVersion = errors.New("unsupported artifact schema version") +) + +type Options struct { + Version *semver.Version + Writer io.Writer +} + +type Artifact interface { + Build() error + Commit() error + Metadata() metadata.ArtifactMetadata + Summary() ([]byte, error) + Write() error +} + +func New(opts Options) (Artifact, error) { + version := opts.Version + if version == nil { + version = currentSchemaVersion + } + + doc, err := newDocumentForVersion(version, opts.Writer) + if err != nil { + return nil, err + } + + return doc, nil +} + +func newDocumentForVersion(version *semver.Version, writer io.Writer) (Artifact, error) { + switch version.Major() { + case 1: + return artifactv1.New(writer) + default: + return nil, fmt.Errorf("%w: %s", ErrUnsupportedSchemaVersion, version.Original()) + } +} diff --git a/otdfctl/migrations/artifact/artifact_test.go b/otdfctl/migrations/artifact/artifact_test.go new file mode 100644 index 0000000000..d458c8be16 --- /dev/null +++ b/otdfctl/migrations/artifact/artifact_test.go @@ -0,0 +1,76 @@ +package artifact + +import ( + "bytes" + "testing" + + "github.com/Masterminds/semver/v3" + artifactmetadata "github.com/opentdf/platform/otdfctl/migrations/artifact/metadata" + artifactv1 "github.com/opentdf/platform/otdfctl/migrations/artifact/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewRejectsUnsupportedSchemaVersion(t *testing.T) { + t.Parallel() + + _, err := New(Options{ + Version: semver.MustParse("v2.0.0"), + }) + require.ErrorIs(t, err, ErrUnsupportedSchemaVersion) +} + +func TestNewRejectsNilWriter(t *testing.T) { + t.Parallel() + + _, err := New(Options{}) + require.ErrorIs(t, err, artifactv1.ErrNilWriter) +} + +func TestNewDefaultsCurrentVersion(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + doc, err := New(Options{Writer: &buf}) + require.NoError(t, err) + + require.NoError(t, doc.Write()) + assert.Contains(t, buf.String(), `"schema": "v1.0.0"`) + assert.Contains(t, buf.String(), `"name": "`+artifactmetadata.ArtifactName+`"`) +} + +func TestArtifactSummaryReturnsEncodedJSON(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + doc, err := New(Options{Writer: &buf}) + require.NoError(t, err) + + summary, err := doc.Summary() + require.NoError(t, err) + assert.JSONEq(t, `{ + "counts": { + "namespaces": 0, + "actions": 0, + "subject_condition_sets": 0, + "subject_mappings": 0, + "registered_resources": 0, + "obligation_triggers": 0, + "skipped": 0 + } + }`, string(summary)) +} + +func TestArtifactBuildAndCommitAreNotImplemented(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + doc, err := New(Options{Writer: &buf}) + require.NoError(t, err) + + buildErr := doc.Build() + require.ErrorIs(t, buildErr, artifactv1.ErrNotImplemented) + + commitErr := doc.Commit() + require.ErrorIs(t, commitErr, artifactv1.ErrNotImplemented) +} diff --git a/otdfctl/migrations/artifact/metadata/metadata.go b/otdfctl/migrations/artifact/metadata/metadata.go new file mode 100644 index 0000000000..d92487aa5e --- /dev/null +++ b/otdfctl/migrations/artifact/metadata/metadata.go @@ -0,0 +1,50 @@ +package metadata + +import ( + "time" + + "github.com/Masterminds/semver/v3" +) + +const ArtifactName = "policy-migration" + +type ArtifactMetadata struct { + SchemaValue string `json:"schema"` + NameValue string `json:"name"` + RunIDValue string `json:"run_id"` + CreatedAtValue time.Time `json:"created_at"` +} + +func New(schema, runID string, createdAt time.Time) ArtifactMetadata { + return ArtifactMetadata{ + SchemaValue: schema, + NameValue: ArtifactName, + RunIDValue: runID, + CreatedAtValue: createdAt, + } +} + +func (m ArtifactMetadata) Schema() *semver.Version { + if m.SchemaValue == "" { + return nil + } + + version, err := semver.NewVersion(m.SchemaValue) + if err != nil { + return nil + } + + return version +} + +func (m ArtifactMetadata) Name() string { + return m.NameValue +} + +func (m ArtifactMetadata) RunID() string { + return m.RunIDValue +} + +func (m ArtifactMetadata) CreatedAt() time.Time { + return m.CreatedAtValue +} diff --git a/otdfctl/migrations/artifact/v1/schema.go b/otdfctl/migrations/artifact/v1/schema.go new file mode 100644 index 0000000000..33c8dd53f8 --- /dev/null +++ b/otdfctl/migrations/artifact/v1/schema.go @@ -0,0 +1,259 @@ +package v1 + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "time" + + "github.com/google/uuid" + artifactmetadata "github.com/opentdf/platform/otdfctl/migrations/artifact/metadata" +) + +const SchemaVersion = "v1.0.0" + +var ( + ErrNotImplemented = errors.New("not implemented") + ErrNilWriter = errors.New("nil writer") + ErrWriteArtifact = errors.New("write artifact") + ErrSummaryArtifact = errors.New("summary artifact") +) + +type artifact struct { + MetadataData artifactmetadata.ArtifactMetadata `json:"metadata"` + SummaryData Summary `json:"summary"` + Skipped []skippedEntry `json:"skipped"` + Namespaces []namespaceIndexEntry `json:"namespaces"` + Actions []actionRecord `json:"actions"` + SubjectConditionSets []subjectConditionSetRecord `json:"subject_condition_sets"` + SubjectMappings []subjectMappingRecord `json:"subject_mappings"` + RegisteredResources []registeredResourceRecord `json:"registered_resources"` + ObligationTriggers []obligationTriggerRecord `json:"obligation_triggers"` + writer io.Writer `json:"-"` +} + +type Summary struct { + Counts SummaryCounts `json:"counts"` +} + +type SummaryCounts struct { + Namespaces int `json:"namespaces"` + Actions int `json:"actions"` + SubjectConditionSets int `json:"subject_condition_sets"` + SubjectMappings int `json:"subject_mappings"` + RegisteredResources int `json:"registered_resources"` + ObligationTriggers int `json:"obligation_triggers"` + Skipped int `json:"skipped"` +} + +type skippedEntry struct { + Type string `json:"type"` + SkippedReasonCode string `json:"skipped_reason_code"` + SkippedReason string `json:"skipped_reason"` + Source skippedSource `json:"source"` + Context skippedContext `json:"context"` +} + +type skippedSource struct { + RegisteredResourceID string `json:"registered_resource_id,omitempty"` + RegisteredResourceValueID string `json:"registered_resource_value_id,omitempty"` + ActionID string `json:"action_id,omitempty"` + AttributeValueID string `json:"attribute_value_id,omitempty"` +} + +type skippedContext struct { + TargetNamespaceID string `json:"target_namespace_id,omitempty"` + TargetNamespaceFQN string `json:"target_namespace_fqn,omitempty"` +} + +type namespaceIndexEntry struct { + FQN string `json:"fqn"` + ID string `json:"id"` + Actions []string `json:"actions"` + SubjectConditionSets []string `json:"subject_condition_sets"` + SubjectMappings []string `json:"subject_mappings"` + RegisteredResources []string `json:"registered_resources"` + ObligationTriggers []string `json:"obligation_triggers"` +} + +type actionRecord struct { + Source actionSource `json:"source"` + Targets []actionTarget `json:"targets"` +} + +type actionSource struct { + ID string `json:"id"` + Name string `json:"name"` + NamespaceID *string `json:"namespace_id"` + IsStandard bool `json:"is_standard"` +} + +type actionTarget struct { + NamespaceID string `json:"namespace_id"` + NamespaceFQN string `json:"namespace_fqn"` + ID string `json:"id"` +} + +type subjectConditionSetRecord struct { + Source subjectConditionSetSource `json:"source"` + Targets []subjectConditionSetTarget `json:"targets"` +} + +type subjectConditionSetSource struct { + ID string `json:"id"` + Name string `json:"name"` + NamespaceID *string `json:"namespace_id"` +} + +type subjectConditionSetTarget struct { + NamespaceID string `json:"namespace_id"` + NamespaceFQN string `json:"namespace_fqn"` + ID string `json:"id"` +} + +type subjectMappingRecord struct { + Source subjectMappingSource `json:"source"` + Targets []subjectMappingTarget `json:"targets"` +} + +type subjectMappingSource struct { + ID string `json:"id"` + ActionIDs []string `json:"action_ids"` + SubjectConditionSetID string `json:"subject_condition_set_id"` + NamespaceID *string `json:"namespace_id"` + AttributeValueID string `json:"attribute_value_id"` +} + +type subjectMappingTarget struct { + NamespaceID string `json:"namespace_id"` + NamespaceFQN string `json:"namespace_fqn"` + ID string `json:"id"` + ActionIDs []string `json:"action_ids"` + SubjectConditionSetID string `json:"subject_condition_set_id"` + AttributeValueID string `json:"attribute_value_id"` +} + +type registeredResourceRecord struct { + Source registeredResourceSource `json:"source"` + Targets []registeredResourceTarget `json:"targets"` +} + +type registeredResourceSource struct { + ID string `json:"id"` + Name string `json:"name"` + NamespaceID *string `json:"namespace_id"` + Values []registeredResourceValue `json:"values"` +} + +type registeredResourceTarget struct { + NamespaceID string `json:"namespace_id"` + NamespaceFQN string `json:"namespace_fqn"` + ID string `json:"id"` + Values []registeredResourceValue `json:"values"` +} + +type registeredResourceValue struct { + ID string `json:"id"` + Value string `json:"value"` + ActionAttributeValues []actionAttributeValue `json:"action_attribute_values"` +} + +type actionAttributeValue struct { + ActionID string `json:"action_id"` + AttributeValueID string `json:"attribute_value_id"` +} + +type obligationTriggerRecord struct { + Source obligationTriggerSource `json:"source"` + Targets []obligationTriggerTarget `json:"targets"` +} + +type obligationTriggerSource struct { + ID string `json:"id"` + NamespaceID string `json:"namespace_id"` + NamespaceFQN string `json:"namespace_fqn"` + ActionID string `json:"action_id"` + ObligationValueID string `json:"obligation_value_id"` + AttributeValueID string `json:"attribute_value_id"` + ClientID string `json:"client_id"` +} + +type obligationTriggerTarget struct { + NamespaceID string `json:"namespace_id"` + NamespaceFQN string `json:"namespace_fqn"` + ActionID string `json:"action_id"` + ObligationValueID string `json:"obligation_value_id"` + AttributeValueID string `json:"attribute_value_id"` + ClientID string `json:"client_id"` + ID string `json:"id"` +} + +func New(writer io.Writer) (*artifact, error) { + if writer == nil { + return nil, ErrNilWriter + } + + return &artifact{ + MetadataData: artifactmetadata.New(SchemaVersion, uuid.NewString(), time.Now().UTC()), + Skipped: []skippedEntry{}, + Namespaces: []namespaceIndexEntry{}, + Actions: []actionRecord{}, + SubjectConditionSets: []subjectConditionSetRecord{}, + SubjectMappings: []subjectMappingRecord{}, + RegisteredResources: []registeredResourceRecord{}, + ObligationTriggers: []obligationTriggerRecord{}, + writer: writer, + }, nil +} + +func (a *artifact) Build() error { + return fmt.Errorf("%w: artifact build for schema %s", ErrNotImplemented, SchemaVersion) +} + +func (a *artifact) Commit() error { + return fmt.Errorf("%w: artifact commit for schema %s", ErrNotImplemented, SchemaVersion) +} + +func (a *artifact) Metadata() artifactmetadata.ArtifactMetadata { + return a.MetadataData +} + +func (a *artifact) Summary() ([]byte, error) { + encoded, err := json.Marshal(a.getSummary()) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrSummaryArtifact, err) + } + + return encoded, nil +} + +func (a *artifact) Write() error { + a.updateSummary() + + encoder := json.NewEncoder(a.writer) + encoder.SetIndent("", " ") + if err := encoder.Encode(a); err != nil { + return fmt.Errorf("%w: %w", ErrWriteArtifact, err) + } + + return nil +} + +func (a *artifact) updateSummary() { + a.SummaryData = a.getSummary() +} + +func (a *artifact) getSummary() Summary { + return Summary{ + Counts: SummaryCounts{ + Namespaces: len(a.Namespaces), + Actions: len(a.Actions), + SubjectConditionSets: len(a.SubjectConditionSets), + SubjectMappings: len(a.SubjectMappings), + RegisteredResources: len(a.RegisteredResources), + ObligationTriggers: len(a.ObligationTriggers), + Skipped: len(a.Skipped), + }, + } +} diff --git a/otdfctl/migrations/artifact/v1/schema_test.go b/otdfctl/migrations/artifact/v1/schema_test.go new file mode 100644 index 0000000000..be5bc5c14c --- /dev/null +++ b/otdfctl/migrations/artifact/v1/schema_test.go @@ -0,0 +1,107 @@ +package v1 + +import ( + "bytes" + "encoding/json" + "testing" + "time" + + artifactmetadata "github.com/opentdf/platform/otdfctl/migrations/artifact/metadata" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewInitializesCanonicalShape(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + doc, err := New(&buf) + require.NoError(t, err) + + require.NotNil(t, doc) + assert.Equal(t, SchemaVersion, doc.MetadataData.SchemaValue) + assert.Equal(t, artifactmetadata.ArtifactName, doc.MetadataData.Name()) + assert.NotEmpty(t, doc.MetadataData.RunID()) + assert.WithinDuration(t, time.Now().UTC(), doc.MetadataData.CreatedAt(), time.Minute) + assert.Empty(t, doc.Actions) + assert.Empty(t, doc.Skipped) + + summaryBytes, err := doc.Summary() + require.NoError(t, err) + + var summary Summary + require.NoError(t, json.Unmarshal(summaryBytes, &summary)) + assert.Equal(t, 0, summary.Counts.Actions) + assert.Equal(t, 0, summary.Counts.Skipped) +} + +func TestSummaryReturnsEncodedJSON(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + doc, err := New(&buf) + require.NoError(t, err) + doc.Actions = append(doc.Actions, actionRecord{}) + doc.Skipped = append(doc.Skipped, skippedEntry{}) + + summaryBytes, err := doc.Summary() + require.NoError(t, err) + + var summary Summary + require.NoError(t, json.Unmarshal(summaryBytes, &summary)) + assert.Equal(t, SummaryCounts{ + Namespaces: 0, + Actions: 1, + SubjectConditionSets: 0, + SubjectMappings: 0, + RegisteredResources: 0, + ObligationTriggers: 0, + Skipped: 1, + }, summary.Counts) +} + +func TestWriteProducesJSONDocument(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + doc, err := New(&buf) + require.NoError(t, err) + doc.Actions = append(doc.Actions, actionRecord{ + Source: actionSource{ + ID: "action-export-legacy", + Name: "export", + IsStandard: false, + }, + Targets: []actionTarget{ + { + NamespaceID: "ns-finance-001", + NamespaceFQN: "https://finance.example.com", + ID: "action-export-finance", + }, + }, + }) + doc.Skipped = append(doc.Skipped, skippedEntry{ + Type: "registered_resource_value_action_attribute_value", + SkippedReasonCode: "ambiguous_target_action", + SkippedReason: "Could not determine a safe target action for this RAAV.", + }) + + require.NoError(t, doc.Write()) + + var decoded artifact + require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded)) + + assert.Equal(t, SchemaVersion, decoded.MetadataData.SchemaValue) + assert.Equal(t, artifactmetadata.ArtifactName, decoded.MetadataData.Name()) + assert.NotEmpty(t, decoded.MetadataData.RunID()) + assert.NotEmpty(t, decoded.MetadataData.CreatedAt()) + assert.Equal(t, 1, decoded.SummaryData.Counts.Actions) + assert.Equal(t, 1, decoded.SummaryData.Counts.Skipped) +} + +func TestNewFailsWithoutWriter(t *testing.T) { + t.Parallel() + + _, err := New(nil) + require.ErrorIs(t, err, ErrNilWriter) +} diff --git a/otdfctl/migrations/namespacedpolicy/actions_execute.go b/otdfctl/migrations/namespacedpolicy/actions_execute.go new file mode 100644 index 0000000000..4eb60c9f7f --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/actions_execute.go @@ -0,0 +1,133 @@ +package namespacedpolicy + +import ( + "context" + "fmt" + + "github.com/opentdf/platform/protocol/go/policy" +) + +func (e *Executor) rememberActionTarget(sourceID string, target *ActionTargetPlan) { + if e == nil || sourceID == "" || target == nil { + return + } + + namespaceKey := namespaceRefKey(target.Namespace) + if namespaceKey == "" { + return + } + + if e.actionTargets == nil { + e.actionTargets = make(map[string]map[string]*ActionTargetPlan) + } + if e.actionTargets[sourceID] == nil { + e.actionTargets[sourceID] = make(map[string]*ActionTargetPlan) + } + + e.actionTargets[sourceID][namespaceKey] = target +} + +func (e *Executor) cachedActionTargetID(sourceID string, namespace *policy.Namespace) string { + if e == nil || sourceID == "" { + return "" + } + + namespaceKey := namespaceRefKey(namespace) + if namespaceKey == "" { + return "" + } + + targets := e.actionTargets[sourceID] + if targets == nil { + return "" + } + + target := targets[namespaceKey] + if target == nil { + return "" + } + + return target.TargetID() +} + +func (e *Executor) executeActions(ctx context.Context, actionPlans []*ActionPlan) error { + for _, actionPlan := range actionPlans { + if actionPlan == nil || actionPlan.Source == nil { + continue + } + + for _, target := range actionPlan.Targets { + if target == nil { + continue + } + + if err := e.executeActionTarget(ctx, actionPlan, target); err != nil { + return err + } + } + } + + return nil +} + +func (e *Executor) executeActionTarget(ctx context.Context, actionPlan *ActionPlan, target *ActionTargetPlan) error { + switch target.Status { + case TargetStatusExistingStandard, TargetStatusAlreadyMigrated: + if target.TargetID() == "" { + errKind := ErrMissingExistingTarget + if target.Status == TargetStatusAlreadyMigrated { + errKind = ErrMissingMigratedTarget + } + return fmt.Errorf("%w: action %q target %q", errKind, actionPlan.Source.GetId(), namespaceLabel(target.Namespace)) + } + e.rememberActionTarget(actionPlan.Source.GetId(), target) + return nil + case TargetStatusCreate: + return e.createActionTarget(ctx, actionPlan, target) + case TargetStatusUnresolved: + return fmt.Errorf("%w: action %q target %q is unresolved: %s", ErrPlanNotExecutable, actionPlan.Source.GetId(), namespaceLabel(target.Namespace), target.Reason) + default: + return fmt.Errorf("%w: action %q target %q has unsupported status %q", ErrUnsupportedStatus, actionPlan.Source.GetId(), namespaceLabel(target.Namespace), target.Status) + } +} + +func (e *Executor) createActionTarget(ctx context.Context, actionPlan *ActionPlan, target *ActionTargetPlan) error { + namespace := namespaceIdentifier(target.Namespace) + if namespace == "" { + return fmt.Errorf("%w: action %q", ErrTargetNamespaceRequired, actionPlan.Source.GetId()) + } + + created, err := e.handler.CreateAction( + ctx, + actionPlan.Source.GetName(), + namespace, + metadataForCreate( + actionPlan.Source.GetId(), + metadataLabels(actionPlan.Source.GetMetadata()), + e.runID, + ), + ) + if err != nil { + target.Execution = &ExecutionResult{ + RunID: e.runID, + Failure: err.Error(), + } + return fmt.Errorf("create action %q in namespace %q: %w", actionPlan.Source.GetId(), namespaceLabel(target.Namespace), err) + } + if created.GetId() == "" { + target.Execution = &ExecutionResult{ + RunID: e.runID, + Failure: ErrMissingCreatedTargetID.Error(), + } + return fmt.Errorf("%w: action %q target %q", ErrMissingCreatedTargetID, actionPlan.Source.GetId(), namespaceLabel(target.Namespace)) + } + + target.Execution = &ExecutionResult{ + RunID: e.runID, + Applied: true, + CreatedTargetID: created.GetId(), + } + e.rememberActionTarget(actionPlan.Source.GetId(), target) + + return nil +} diff --git a/otdfctl/migrations/namespacedpolicy/actions_execute_test.go b/otdfctl/migrations/namespacedpolicy/actions_execute_test.go new file mode 100644 index 0000000000..68e5e3bdde --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/actions_execute_test.go @@ -0,0 +1,309 @@ +package namespacedpolicy + +import ( + "testing" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecuteActions(t *testing.T) { + t.Parallel() + + namespace1 := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + namespace2 := &policy.Namespace{Id: "ns-2", Fqn: "https://example.net"} + namespace3 := &policy.Namespace{Id: "ns-3", Fqn: "https://example.org"} + + tests := []struct { + name string + plan *Plan + handler *mockExecutorHandler + runID string + wantErr *expectedError + assert func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, plan *Plan) + }{ + { + name: "handles created, existing, and already migrated action targets", + plan: &Plan{ + Scopes: []Scope{ScopeActions}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{ + Id: "action-1", + Name: "decrypt", + Metadata: &common.Metadata{ + Labels: map[string]string{ + "owner": "policy-team", + "env": "dev", + }, + }, + }, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + }, + { + Namespace: namespace2, + Status: TargetStatusExistingStandard, + Existing: &policy.Action{Id: "standard-action"}, + }, + { + Namespace: namespace3, + Status: TargetStatusAlreadyMigrated, + Existing: &policy.Action{Id: "migrated-action"}, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{ + results: map[string]map[string]*policy.Action{ + "decrypt": { + "ns-1": {Id: "created-action-1", Name: "decrypt"}, + }, + }, + }, + runID: "run-123", + assert: func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.NoError(t, err) + require.Contains(t, handler.created, "decrypt") + require.Contains(t, handler.created["decrypt"], "ns-1") + assert.Len(t, handler.created["decrypt"], 1) + assert.Equal(t, "decrypt", handler.created["decrypt"]["ns-1"].Name) + assert.Equal(t, "ns-1", handler.created["decrypt"]["ns-1"].Namespace) + assert.Equal(t, map[string]string{ + "owner": "policy-team", + "env": "dev", + migrationLabelMigratedFrom: "action-1", + migrationLabelRun: "run-123", + }, handler.created["decrypt"]["ns-1"].Metadata.GetLabels()) + + createdTarget := plan.Actions[0].Targets[0] + assert.Equal(t, TargetStatusCreate, createdTarget.Status) + assert.Nil(t, createdTarget.Existing) + require.NotNil(t, createdTarget.Execution) + assert.True(t, createdTarget.Execution.Applied) + assert.Equal(t, "created-action-1", createdTarget.Execution.CreatedTargetID) + assert.Equal(t, "run-123", createdTarget.Execution.RunID) + assert.Equal(t, "created-action-1", createdTarget.TargetID()) + + existingTarget := plan.Actions[0].Targets[1] + assert.Equal(t, "standard-action", existingTarget.TargetID()) + + migratedTarget := plan.Actions[0].Targets[2] + assert.Equal(t, "migrated-action", migratedTarget.TargetID()) + + assert.Equal(t, "created-action-1", executor.cachedActionTargetID("action-1", namespace1)) + assert.Equal(t, "standard-action", executor.cachedActionTargetID("action-1", namespace2)) + assert.Equal(t, "migrated-action", executor.cachedActionTargetID("action-1", namespace3)) + assert.Empty(t, executor.cachedActionTargetID("action-2", namespace1)) + }, + }, + { + name: "returns not executable for unresolved target status", + plan: &Plan{ + Scopes: []Scope{ScopeActions}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{Id: "action-1", Name: "decrypt"}, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusUnresolved, + Reason: "missing target namespace mapping", + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError( + ErrPlanNotExecutable, + `action %q target %q is unresolved: %s`, + "action-1", + namespace1.GetFqn(), + "missing target namespace mapping", + ), + assert: func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.created) + assert.Empty(t, executor.cachedActionTargetID("action-1", namespace1)) + }, + }, + { + name: "returns error for missing existing standard target id", + plan: &Plan{ + Scopes: []Scope{ScopeActions}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{Id: "action-1", Name: "decrypt"}, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusExistingStandard, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError(ErrMissingExistingTarget, `action %q target %q`, "action-1", namespace1.GetFqn()), + assert: func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.created) + assert.Empty(t, executor.cachedActionTargetID("action-1", namespace1)) + }, + }, + { + name: "returns error for missing already migrated target id", + plan: &Plan{ + Scopes: []Scope{ScopeActions}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{Id: "action-1", Name: "decrypt"}, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError(ErrMissingMigratedTarget, `action %q target %q`, "action-1", namespace1.GetFqn()), + assert: func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.created) + assert.Empty(t, executor.cachedActionTargetID("action-1", namespace1)) + }, + }, + { + name: "returns error for missing target namespace", + plan: &Plan{ + Scopes: []Scope{ScopeActions}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{Id: "action-1", Name: "decrypt"}, + Targets: []*ActionTargetPlan{ + { + Status: TargetStatusCreate, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError(ErrTargetNamespaceRequired, `action %q`, "action-1"), + assert: func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.created) + assert.Empty(t, executor.cachedActionTargetID("action-1", nil)) + }, + }, + { + name: "returns error for missing created target id", + plan: &Plan{ + Scopes: []Scope{ScopeActions}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{Id: "action-1", Name: "decrypt"}, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{ + results: map[string]map[string]*policy.Action{ + "decrypt": { + "ns-1": {}, + }, + }, + }, + wantErr: wantError(ErrMissingCreatedTargetID, `action %q target %q`, "action-1", namespace1.GetFqn()), + assert: func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.Error(t, err) + require.Contains(t, handler.created, "decrypt") + require.NotNil(t, plan.Actions[0].Targets[0].Execution) + assert.Equal(t, ErrMissingCreatedTargetID.Error(), plan.Actions[0].Targets[0].Execution.Failure) + assert.Empty(t, executor.cachedActionTargetID("action-1", namespace1)) + }, + }, + { + name: "returns error for unsupported target status", + plan: &Plan{ + Scopes: []Scope{ScopeActions}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{Id: "action-1", Name: "decrypt"}, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatus("bogus"), + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError( + ErrUnsupportedStatus, + `action %q target %q has unsupported status %q`, + "action-1", + namespace1.GetFqn(), + TargetStatus("bogus"), + ), + assert: func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.created) + assert.Empty(t, executor.cachedActionTargetID("action-1", namespace1)) + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + executor, err := NewExecutor(tt.handler) + require.NoError(t, err) + if tt.runID != "" { + executor.runID = tt.runID + } + + err = executor.Execute(t.Context(), tt.plan) + switch { + case tt.wantErr != nil: + require.Error(t, err) + require.ErrorIs(t, err, tt.wantErr.is) + require.EqualError(t, err, tt.wantErr.message) + default: + require.NoError(t, err) + } + + tt.assert(t, err, executor, tt.handler, tt.plan) + }) + } +} diff --git a/otdfctl/migrations/namespacedpolicy/canonical.go b/otdfctl/migrations/namespacedpolicy/canonical.go new file mode 100644 index 0000000000..b30fc3e2c2 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/canonical.go @@ -0,0 +1,312 @@ +package namespacedpolicy + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/opentdf/platform/protocol/go/policy" +) + +type registeredResourceValueCanonical struct { + Value string `json:"value"` + ActionAttributeValues []string `json:"action_attribute_values"` +} + +func actionCanonicalEqual(source, target *policy.Action) bool { + s := canonicalActionName(source) + return s != "" && s == canonicalActionName(target) +} + +func subjectConditionSetCanonicalEqual(source, target *policy.SubjectConditionSet) bool { + s := canonicalSubjectConditionSet(source) + return s != "" && s == canonicalSubjectConditionSet(target) +} + +func subjectMappingCanonicalEqual(source, target *policy.SubjectMapping) bool { + s := canonicalSubjectMapping(source) + return s != "" && s == canonicalSubjectMapping(target) +} + +func obligationTriggerCanonicalEqual(source, target *policy.ObligationTrigger) bool { + s := canonicalObligationTrigger(source) + return s != "" && s == canonicalObligationTrigger(target) +} + +func registeredResourceCanonicalEqual(source, target *policy.RegisteredResource) bool { + s := canonicalRegisteredResource(source) + return s != "" && s == canonicalRegisteredResource(target) +} + +func canonicalActionName(action *policy.Action) string { + if action == nil { + return "" + } + return strings.ToLower(strings.TrimSpace(action.GetName())) +} + +// canonicalSubjectConditionSet produces a deterministic string key from the +// semantically meaningful fields of a SubjectConditionSet. We extract into +// plain Go types and sort at every level rather than relying on protobuf +// serialization (protojson.Marshal, proto.Marshal with Deterministic: true), +// because neither guarantees stable output across library versions or builds. +// Canonical comparison is only performed within a single planning run, but +// explicit field extraction makes the stability guarantee self-evident. +func canonicalSubjectConditionSet(scs *policy.SubjectConditionSet) string { + if scs == nil || len(scs.GetSubjectSets()) == 0 { + return "" + } + + sets := make([]canonicalSubjectSetEntry, 0, len(scs.GetSubjectSets())) + for _, ss := range scs.GetSubjectSets() { + if ss == nil { + continue + } + sets = append(sets, normalizeSubjectSet(ss)) + } + if len(sets) == 0 { + return "" + } + sortByJSON(sets) + + encoded, err := json.Marshal(sets) + if err != nil { + return "" + } + return string(encoded) +} + +func canonicalSubjectMapping(mapping *policy.SubjectMapping) string { + if mapping == nil { + return "" + } + + payload := struct { + AttributeValueFQN string `json:"attribute_value_fqn"` + ActionNames []string `json:"action_names"` + SubjectSetKey string `json:"subject_condition_set"` + }{ + AttributeValueFQN: strings.TrimSpace(mapping.GetAttributeValue().GetFqn()), + ActionNames: canonicalActionNames(mapping.GetActions()), + SubjectSetKey: canonicalSubjectConditionSet(mapping.GetSubjectConditionSet()), + } + if payload.AttributeValueFQN == "" || payload.SubjectSetKey == "" { + return "" + } + + encoded, err := json.Marshal(payload) + if err != nil { + return "" + } + return string(encoded) +} + +func canonicalObligationTrigger(trigger *policy.ObligationTrigger) string { + if trigger == nil { + return "" + } + + payload := struct { + AttributeValueFQN string `json:"attribute_value_fqn"` + ActionName string `json:"action_name"` + ObligationValueFQN string `json:"obligation_value_fqn"` + Context string `json:"context"` + }{ + AttributeValueFQN: strings.TrimSpace(trigger.GetAttributeValue().GetFqn()), + ActionName: canonicalActionName(trigger.GetAction()), + ObligationValueFQN: strings.TrimSpace(trigger.GetObligationValue().GetFqn()), + Context: canonicalObligationTriggerContext(trigger.GetContext()), + } + if payload.AttributeValueFQN == "" || payload.ActionName == "" || payload.ObligationValueFQN == "" { + return "" + } + + encoded, err := json.Marshal(payload) + if err != nil { + return "" + } + return string(encoded) +} + +// canonicalObligationTriggerContext produces a deterministic string key from +// RequestContext fields. See canonicalSubjectConditionSet for rationale on +// avoiding protobuf serialization. +func canonicalObligationTriggerContext(contexts []*policy.RequestContext) string { + if len(contexts) == 0 { + return "" + } + + entries := make([]canonicalRequestContextEntry, 0, len(contexts)) + for _, rc := range contexts { + if rc == nil || rc.GetPep() == nil { + continue + } + entries = append(entries, canonicalRequestContextEntry{ + ClientID: strings.TrimSpace(rc.GetPep().GetClientId()), + }) + } + + if len(entries) == 0 { + return "" + } + + sort.SliceStable(entries, func(i, j int) bool { + return entries[i].ClientID < entries[j].ClientID + }) + + encoded, err := json.Marshal(entries) + if err != nil { + return "" + } + return string(encoded) +} + +type canonicalSubjectSetEntry struct { + ConditionGroups []canonicalConditionGroupEntry `json:"condition_groups"` +} + +type canonicalConditionGroupEntry struct { + Conditions []canonicalConditionEntry `json:"conditions"` + BooleanOperator int32 `json:"boolean_operator"` +} + +type canonicalConditionEntry struct { + Selector string `json:"selector"` + Operator int32 `json:"operator"` + Values []string `json:"values"` +} + +type canonicalRequestContextEntry struct { + ClientID string `json:"client_id"` +} + +func normalizeSubjectSet(ss *policy.SubjectSet) canonicalSubjectSetEntry { + groups := make([]canonicalConditionGroupEntry, 0, len(ss.GetConditionGroups())) + for _, cg := range ss.GetConditionGroups() { + if cg == nil { + continue + } + groups = append(groups, normalizeConditionGroup(cg)) + } + sortByJSON(groups) + return canonicalSubjectSetEntry{ConditionGroups: groups} +} + +func normalizeConditionGroup(cg *policy.ConditionGroup) canonicalConditionGroupEntry { + conditions := make([]canonicalConditionEntry, 0, len(cg.GetConditions())) + for _, c := range cg.GetConditions() { + if c == nil { + continue + } + values := append([]string(nil), c.GetSubjectExternalValues()...) + sort.Strings(values) + conditions = append(conditions, canonicalConditionEntry{ + Selector: strings.TrimSpace(c.GetSubjectExternalSelectorValue()), + Operator: int32(c.GetOperator()), + Values: values, + }) + } + sort.SliceStable(conditions, func(i, j int) bool { + if conditions[i].Selector != conditions[j].Selector { + return conditions[i].Selector < conditions[j].Selector + } + if conditions[i].Operator != conditions[j].Operator { + return conditions[i].Operator < conditions[j].Operator + } + return strings.Join(conditions[i].Values, ",") < strings.Join(conditions[j].Values, ",") + }) + return canonicalConditionGroupEntry{ + Conditions: conditions, + BooleanOperator: int32(cg.GetBooleanOperator()), + } +} + +func sortByJSON[T any](items []T) { + type keyedItem struct { + value T + key string + } + + keyed := make([]keyedItem, 0, len(items)) + for _, item := range items { + k, _ := json.Marshal(item) + keyed = append(keyed, keyedItem{ + value: item, + key: string(k), + }) + } + + sort.SliceStable(keyed, func(i, j int) bool { + return keyed[i].key < keyed[j].key + }) + for i := range keyed { + items[i] = keyed[i].value + } +} + +// TODO: Revisit this. Probably can be simpler. +func canonicalRegisteredResource(resource *policy.RegisteredResource) string { + if resource == nil { + return "" + } + + values := make([]registeredResourceValueCanonical, 0, len(resource.GetValues())) + for _, value := range resource.GetValues() { + if value == nil { + continue + } + + aavs := make([]string, 0, len(value.GetActionAttributeValues())) + for _, aav := range value.GetActionAttributeValues() { + if aav == nil { + continue + } + key := fmt.Sprintf("%s|%s", canonicalActionName(aav.GetAction()), strings.TrimSpace(aav.GetAttributeValue().GetFqn())) + if key == "|" { + continue + } + aavs = append(aavs, key) + } + sort.Strings(aavs) + + values = append(values, registeredResourceValueCanonical{ + Value: strings.ToLower(strings.TrimSpace(value.GetValue())), + ActionAttributeValues: aavs, + }) + } + sort.Slice(values, func(i, j int) bool { + if values[i].Value == values[j].Value { + return strings.Join(values[i].ActionAttributeValues, ",") < strings.Join(values[j].ActionAttributeValues, ",") + } + return values[i].Value < values[j].Value + }) + + payload := struct { + Name string `json:"name"` + Values []registeredResourceValueCanonical `json:"values"` + }{ + Name: strings.ToLower(strings.TrimSpace(resource.GetName())), + Values: values, + } + if payload.Name == "" { + return "" + } + + encoded, err := json.Marshal(payload) + if err != nil { + return "" + } + return string(encoded) +} + +func canonicalActionNames(actions []*policy.Action) []string { + names := make([]string, 0, len(actions)) + for _, action := range actions { + if name := canonicalActionName(action); name != "" { + names = append(names, name) + } + } + sort.Strings(names) + return names +} diff --git a/otdfctl/migrations/namespacedpolicy/canonical_test.go b/otdfctl/migrations/namespacedpolicy/canonical_test.go new file mode 100644 index 0000000000..8ecc3141ce --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/canonical_test.go @@ -0,0 +1,294 @@ +package namespacedpolicy + +import ( + "testing" + + "github.com/opentdf/platform/protocol/go/policy" + "github.com/stretchr/testify/assert" +) + +func TestSortByJSONOrdersItemsByEncodedKey(t *testing.T) { + t.Parallel() + + items := []struct { + Name string `json:"name"` + }{ + {Name: "b"}, + {Name: "c"}, + {Name: "a"}, + } + + sortByJSON(items) + + assert.Equal(t, []struct { + Name string `json:"name"` + }{ + {Name: "a"}, + {Name: "b"}, + {Name: "c"}, + }, items) +} + +func TestCanonicalRegisteredResourceIgnoresValueAndBindingOrder(t *testing.T) { + t.Parallel() + + left := testRegisteredResource( + "resource-left", + " Documents ", + testRegisteredResourceValue( + "Prod", + testActionAttributeValue( + "action-read", + "Read", + testAttributeValue("https://example.com/attr/classification/value/public", nil), + ), + testActionAttributeValue( + "action-write", + "Write", + testAttributeValue("https://example.com/attr/classification/value/internal", nil), + ), + ), + testRegisteredResourceValue( + "Dev", + testActionAttributeValue( + "action-read", + "Read", + testAttributeValue("https://example.com/attr/classification/value/public", nil), + ), + ), + ) + right := testRegisteredResource( + "resource-right", + "documents", + testRegisteredResourceValue( + "dev", + testActionAttributeValue( + "action-read", + "read", + testAttributeValue("https://example.com/attr/classification/value/public", nil), + ), + ), + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + "action-write", + "write", + testAttributeValue("https://example.com/attr/classification/value/internal", nil), + ), + testActionAttributeValue( + "action-read", + "read", + testAttributeValue("https://example.com/attr/classification/value/public", nil), + ), + ), + ) + + assert.Equal(t, canonicalRegisteredResource(left), canonicalRegisteredResource(right)) + assert.True(t, registeredResourceCanonicalEqual(left, right)) +} + +func TestCanonicalSubjectConditionSetIgnoresOrderAtEveryLevel(t *testing.T) { + t.Parallel() + + condA := &policy.Condition{ + SubjectExternalSelectorValue: ".department", + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + SubjectExternalValues: []string{"engineering", "security"}, + } + condB := &policy.Condition{ + SubjectExternalSelectorValue: ".role", + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + SubjectExternalValues: []string{"admin"}, + } + groupAB := &policy.ConditionGroup{ + Conditions: []*policy.Condition{condA, condB}, + BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_AND, + } + groupBA := &policy.ConditionGroup{ + Conditions: []*policy.Condition{condB, condA}, + BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_AND, + } + + left := &policy.SubjectConditionSet{ + Id: "scs-left", + SubjectSets: []*policy.SubjectSet{ + {ConditionGroups: []*policy.ConditionGroup{groupAB}}, + {ConditionGroups: []*policy.ConditionGroup{groupBA}}, + }, + } + right := &policy.SubjectConditionSet{ + Id: "scs-right", + SubjectSets: []*policy.SubjectSet{ + {ConditionGroups: []*policy.ConditionGroup{groupBA}}, + {ConditionGroups: []*policy.ConditionGroup{groupAB}}, + }, + } + + assert.Equal(t, canonicalSubjectConditionSet(left), canonicalSubjectConditionSet(right)) + assert.True(t, subjectConditionSetCanonicalEqual(left, right)) +} + +func TestCanonicalSubjectConditionSetSortsValuesWithinConditions(t *testing.T) { + t.Parallel() + + left := &policy.SubjectConditionSet{ + SubjectSets: []*policy.SubjectSet{ + {ConditionGroups: []*policy.ConditionGroup{ + { + Conditions: []*policy.Condition{ + { + SubjectExternalSelectorValue: ".role", + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + SubjectExternalValues: []string{"admin", "editor", "viewer"}, + }, + }, + BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_AND, + }, + }}, + }, + } + right := &policy.SubjectConditionSet{ + SubjectSets: []*policy.SubjectSet{ + {ConditionGroups: []*policy.ConditionGroup{ + { + Conditions: []*policy.Condition{ + { + SubjectExternalSelectorValue: ".role", + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + SubjectExternalValues: []string{"viewer", "admin", "editor"}, + }, + }, + BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_AND, + }, + }}, + }, + } + + assert.True(t, subjectConditionSetCanonicalEqual(left, right)) +} + +func TestCanonicalSubjectConditionSetDistinguishesDifferentConditions(t *testing.T) { + t.Parallel() + + left := &policy.SubjectConditionSet{ + SubjectSets: []*policy.SubjectSet{ + {ConditionGroups: []*policy.ConditionGroup{ + { + Conditions: []*policy.Condition{ + { + SubjectExternalSelectorValue: ".role", + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + SubjectExternalValues: []string{"admin"}, + }, + }, + BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_AND, + }, + }}, + }, + } + right := &policy.SubjectConditionSet{ + SubjectSets: []*policy.SubjectSet{ + {ConditionGroups: []*policy.ConditionGroup{ + { + Conditions: []*policy.Condition{ + { + SubjectExternalSelectorValue: ".role", + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + SubjectExternalValues: []string{"editor"}, + }, + }, + BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_AND, + }, + }}, + }, + } + + assert.False(t, subjectConditionSetCanonicalEqual(left, right)) +} + +func TestCanonicalSubjectConditionSetReturnsEmptyForNilOrEmpty(t *testing.T) { + t.Parallel() + + assert.Empty(t, canonicalSubjectConditionSet(nil)) + assert.Empty(t, canonicalSubjectConditionSet(&policy.SubjectConditionSet{})) + assert.Empty(t, canonicalSubjectConditionSet(&policy.SubjectConditionSet{ + SubjectSets: []*policy.SubjectSet{nil}, + })) +} + +func TestCanonicalObligationTriggerContextIgnoresOrder(t *testing.T) { + t.Parallel() + + left := []*policy.RequestContext{ + {Pep: &policy.PolicyEnforcementPoint{ClientId: "ingress-client"}}, + {Pep: &policy.PolicyEnforcementPoint{ClientId: "egress-client"}}, + } + right := []*policy.RequestContext{ + {Pep: &policy.PolicyEnforcementPoint{ClientId: "egress-client"}}, + {Pep: &policy.PolicyEnforcementPoint{ClientId: "ingress-client"}}, + } + + assert.Equal(t, canonicalObligationTriggerContext(left), canonicalObligationTriggerContext(right)) +} + +func TestCanonicalObligationTriggerContextSkipsNilEntries(t *testing.T) { + t.Parallel() + + withNils := []*policy.RequestContext{ + nil, + {Pep: &policy.PolicyEnforcementPoint{ClientId: "client-a"}}, + {Pep: nil}, + } + clean := []*policy.RequestContext{ + {Pep: &policy.PolicyEnforcementPoint{ClientId: "client-a"}}, + } + + assert.Equal(t, canonicalObligationTriggerContext(withNils), canonicalObligationTriggerContext(clean)) +} + +func TestCanonicalObligationTriggerContextReturnsEmptyForNilOrEmpty(t *testing.T) { + t.Parallel() + + assert.Empty(t, canonicalObligationTriggerContext(nil)) + assert.Empty(t, canonicalObligationTriggerContext([]*policy.RequestContext{})) + assert.Empty(t, canonicalObligationTriggerContext([]*policy.RequestContext{nil})) +} + +func TestCanonicalObligationTriggerIncludesContext(t *testing.T) { + t.Parallel() + + base := &policy.ObligationTrigger{ + Action: &policy.Action{Id: "action-1", Name: "decrypt"}, + AttributeValue: &policy.Value{Id: "value-1", Fqn: "https://attr.example.com/value/secret"}, + ObligationValue: &policy.ObligationValue{ + Id: "ov-1", + Fqn: "https://obligation.example.com/value/notify", + }, + } + + left := protoCloneTrigger(base) + left.Context = []*policy.RequestContext{ + {Pep: &policy.PolicyEnforcementPoint{ClientId: "ingress-client"}}, + } + + right := protoCloneTrigger(base) + right.Context = []*policy.RequestContext{ + {Pep: &policy.PolicyEnforcementPoint{ClientId: "egress-client"}}, + } + + assert.NotEqual(t, canonicalObligationTrigger(left), canonicalObligationTrigger(right)) +} + +func protoCloneTrigger(trigger *policy.ObligationTrigger) *policy.ObligationTrigger { + if trigger == nil { + return nil + } + + return &policy.ObligationTrigger{ + Id: trigger.GetId(), + ObligationValue: trigger.GetObligationValue(), + Action: trigger.GetAction(), + AttributeValue: trigger.GetAttributeValue(), + Metadata: trigger.GetMetadata(), + } +} diff --git a/otdfctl/migrations/namespacedpolicy/derived.go b/otdfctl/migrations/namespacedpolicy/derived.go new file mode 100644 index 0000000000..727e0c0f79 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/derived.go @@ -0,0 +1,500 @@ +package namespacedpolicy + +import ( + "errors" + "fmt" + + "github.com/opentdf/platform/protocol/go/policy" +) + +type DerivedTargets struct { + Scopes []Scope + Actions []*DerivedAction + SubjectConditionSets []*DerivedSubjectConditionSet + SubjectMappings []*DerivedSubjectMapping + RegisteredResources []*DerivedRegisteredResource + ObligationTriggers []*DerivedObligationTrigger +} + +type DerivedAction struct { + Source *policy.Action + References []*ActionReference + Targets []*policy.Namespace +} + +type DerivedSubjectConditionSet struct { + Source *policy.SubjectConditionSet + Targets []*policy.Namespace +} + +type DerivedSubjectMapping struct { + Source *policy.SubjectMapping + Target *policy.Namespace +} + +type DerivedRegisteredResource struct { + Source *policy.RegisteredResource + Target *policy.Namespace + Unresolved *Unresolved +} + +type DerivedObligationTrigger struct { + Source *policy.ObligationTrigger + Target *policy.Namespace +} + +type targetDeriver struct { + namespaces []*policy.Namespace + namespaceByID map[string]*policy.Namespace + namespaceByFQN map[string]*policy.Namespace + actionTargetsByID map[string]*namespaceAccumulator + actionRefsByID map[string]*actionReferenceAccumulator + scsTargetsByID map[string]*namespaceAccumulator +} + +var errSkipRegisteredResource = errors.New("skip registered resource") + +func deriveTargets(retrieved *Retrieved, namespaces []*policy.Namespace) (*DerivedTargets, error) { + if retrieved == nil { + return nil, ErrNilRetrieved + } + + deriver := newTargetDeriver(namespaces) + derived := &DerivedTargets{ + Scopes: append([]Scope(nil), retrieved.Scopes...), + Actions: make([]*DerivedAction, 0, len(retrieved.Candidates.Actions)), + SubjectConditionSets: make([]*DerivedSubjectConditionSet, 0, len(retrieved.Candidates.SubjectConditionSets)), + SubjectMappings: make([]*DerivedSubjectMapping, 0, len(retrieved.Candidates.SubjectMappings)), + RegisteredResources: make([]*DerivedRegisteredResource, 0, len(retrieved.Candidates.RegisteredResources)), + ObligationTriggers: make([]*DerivedObligationTrigger, 0, len(retrieved.Candidates.ObligationTriggers)), + } + + for _, mapping := range retrieved.Candidates.SubjectMappings { + item, err := deriver.deriveSubjectMapping(mapping) + if err != nil { + return nil, err + } + derived.SubjectMappings = append(derived.SubjectMappings, item) + deriver.observeSubjectMapping(item) + } + + for _, resource := range retrieved.Candidates.RegisteredResources { + item, err := deriver.deriveRegisteredResource(resource) + if err != nil { + if errors.Is(err, errSkipRegisteredResource) { + continue + } + return nil, err + } + derived.RegisteredResources = append(derived.RegisteredResources, item) + deriver.observeRegisteredResource(item) + } + + for _, trigger := range retrieved.Candidates.ObligationTriggers { + item, err := deriver.deriveObligationTrigger(trigger) + if err != nil { + return nil, err + } + derived.ObligationTriggers = append(derived.ObligationTriggers, item) + deriver.observeObligationTrigger(item) + } + + for _, action := range retrieved.Candidates.Actions { + item, err := deriver.deriveAction(action) + if err != nil { + return nil, err + } + derived.Actions = append(derived.Actions, item) + } + + for _, scs := range retrieved.Candidates.SubjectConditionSets { + item, err := deriver.deriveSubjectConditionSet(scs) + if err != nil { + return nil, err + } + derived.SubjectConditionSets = append(derived.SubjectConditionSets, item) + } + + return derived, nil +} + +func newTargetDeriver(namespaces []*policy.Namespace) *targetDeriver { + namespaceByID := make(map[string]*policy.Namespace, len(namespaces)) + namespaceByFQN := make(map[string]*policy.Namespace, len(namespaces)) + for _, namespace := range namespaces { + if namespace == nil { + continue + } + if id := namespace.GetId(); id != "" { + namespaceByID[id] = namespace + } + if fqn := namespace.GetFqn(); fqn != "" { + namespaceByFQN[fqn] = namespace + } + } + + return &targetDeriver{ + namespaces: namespaces, + namespaceByID: namespaceByID, + namespaceByFQN: namespaceByFQN, + actionTargetsByID: make(map[string]*namespaceAccumulator), + actionRefsByID: make(map[string]*actionReferenceAccumulator), + scsTargetsByID: make(map[string]*namespaceAccumulator), + } +} + +func (d *targetDeriver) deriveSubjectMapping(mapping *policy.SubjectMapping) (*DerivedSubjectMapping, error) { + item := &DerivedSubjectMapping{Source: mapping} + namespace, err := d.resolveNamespace(namespaceFromAttributeValue(mapping.GetAttributeValue())) + if err != nil { + return nil, fmt.Errorf("subject mapping %q: %w", mapping.GetId(), err) + } + + item.Target = namespace + return item, nil +} + +func (d *targetDeriver) deriveRegisteredResource(resource *policy.RegisteredResource) (*DerivedRegisteredResource, error) { + item := &DerivedRegisteredResource{Source: resource} + if resource == nil { + return nil, fmt.Errorf("%w: registered resource is empty", ErrUndeterminedTargetMapping) + } + + namespaceRef, ok := registeredResourceNamespaceRef(resource) + if !ok { + // Registered resources only resolve when their action-attribute values + // imply exactly one target namespace. No AAV-derived namespace, or AAVs + // spanning multiple namespaces, leaves the RR unresolved here. + if hasRegisteredResourceActionAttributeValues(resource) { + item.Unresolved = &Unresolved{ + Reason: UnresolvedReasonRegisteredResourceConflictingNamespaces, + Message: fmt.Errorf("%w: registered resource spans multiple target namespaces", ErrUndeterminedTargetMapping).Error(), + } + return item, nil + } + // Skip registered resources that have no action-attribute values because they do not provide a derivable namespace target. + return nil, errSkipRegisteredResource + } + + namespace, err := d.resolveNamespace(namespaceRef) + if err != nil { + return nil, fmt.Errorf("registered resource %q: %w", resource.GetId(), err) + } + + item.Target = namespace + return item, nil +} + +func (d *targetDeriver) deriveObligationTrigger(trigger *policy.ObligationTrigger) (*DerivedObligationTrigger, error) { + item := &DerivedObligationTrigger{Source: trigger} + namespace, err := d.resolveNamespace(namespaceFromObligationValue(trigger.GetObligationValue())) + if err != nil { + return nil, fmt.Errorf("obligation trigger %q: %w", trigger.GetId(), err) + } + + item.Target = namespace + return item, nil +} + +func (d *targetDeriver) deriveAction(action *policy.Action) (*DerivedAction, error) { + item := &DerivedAction{ + Source: action, + } + if action == nil { + return nil, fmt.Errorf("%w: empty action candidate", ErrUndeterminedTargetMapping) + } + if refs := d.actionRefsByID[action.GetId()]; refs != nil { + item.References = refs.slice() + } + targets := d.targets(d.actionTargetsByID[action.GetId()]) + if len(targets) == 0 { + if len(item.References) > 0 { + return nil, fmt.Errorf("%w: no target namespaces were discovered for action %q", ErrUndeterminedTargetMapping, action.GetId()) + } + return item, nil + } + + item.Targets = targets + return item, nil +} + +func (d *targetDeriver) deriveSubjectConditionSet(scs *policy.SubjectConditionSet) (*DerivedSubjectConditionSet, error) { + item := &DerivedSubjectConditionSet{Source: scs} + if scs == nil { + return nil, fmt.Errorf("%w: empty subject condition set candidate", ErrUndeterminedTargetMapping) + } + targets := d.targets(d.scsTargetsByID[scs.GetId()]) + if len(targets) == 0 { + return nil, fmt.Errorf("%w: no target namespaces were discovered for subject condition set %q", ErrUndeterminedTargetMapping, scs.GetId()) + } + + item.Targets = targets + return item, nil +} + +func (d *targetDeriver) observeSubjectMapping(item *DerivedSubjectMapping) { + if item == nil || item.Source == nil || item.Target == nil { + return + } + + for _, action := range item.Source.GetActions() { + d.addActionTarget(action.GetId(), item.Target) + d.addActionReference(action.GetId(), ActionReferenceKindSubjectMapping, item.Source.GetId(), item.Target) + } + + if scsID := item.Source.GetSubjectConditionSet().GetId(); scsID != "" { + d.addSubjectConditionSetTarget(scsID, item.Target) + } +} + +func (d *targetDeriver) observeRegisteredResource(item *DerivedRegisteredResource) { + if item == nil || item.Source == nil || item.Target == nil { + return + } + + for _, value := range item.Source.GetValues() { + for _, aav := range value.GetActionAttributeValues() { + d.addActionTarget(aav.GetAction().GetId(), item.Target) + d.addActionReference(aav.GetAction().GetId(), ActionReferenceKindRegisteredResource, item.Source.GetId(), item.Target) + } + } +} + +func (d *targetDeriver) observeObligationTrigger(item *DerivedObligationTrigger) { + if item == nil || item.Source == nil || item.Target == nil { + return + } + + d.addActionTarget(item.Source.GetAction().GetId(), item.Target) + d.addActionReference(item.Source.GetAction().GetId(), ActionReferenceKindObligationTrigger, item.Source.GetId(), item.Target) +} + +func (d *targetDeriver) addActionTarget(actionID string, namespace *policy.Namespace) { + if actionID == "" || namespace == nil { + return + } + + targets := d.actionTargetsByID[actionID] + if targets == nil { + targets = newNamespaceAccumulator() + d.actionTargetsByID[actionID] = targets + } + targets.add(namespace) +} + +func (d *targetDeriver) addActionReference(actionID string, kind ActionReferenceKind, id string, namespace *policy.Namespace) { + if actionID == "" || id == "" || kind == "" { + return + } + + references := d.actionRefsByID[actionID] + if references == nil { + references = newActionReferenceAccumulator() + d.actionRefsByID[actionID] = references + } + + references.add(&ActionReference{ + Kind: kind, + ID: id, + Namespace: namespace, + }) +} + +func (d *targetDeriver) addSubjectConditionSetTarget(scsID string, namespace *policy.Namespace) { + if scsID == "" || namespace == nil { + return + } + + targets := d.scsTargetsByID[scsID] + if targets == nil { + targets = newNamespaceAccumulator() + d.scsTargetsByID[scsID] = targets + } + targets.add(namespace) +} + +func (d *targetDeriver) targets(targets *namespaceAccumulator) []*policy.Namespace { + if targets == nil { + return nil + } + + return targets.slice() +} + +func (d *targetDeriver) resolveNamespace(namespace *policy.Namespace) (*policy.Namespace, error) { + if namespace == nil { + return nil, fmt.Errorf("%w: empty namespace reference", ErrUndeterminedTargetMapping) + } + if id := namespace.GetId(); id != "" { + if resolved, ok := d.namespaceByID[id]; ok { + return resolved, nil + } + } + if fqn := namespace.GetFqn(); fqn != "" { + if resolved, ok := d.namespaceByFQN[fqn]; ok { + return resolved, nil + } + } + + return nil, fmt.Errorf("%w: id=%q fqn=%q", ErrMissingTargetNamespace, namespace.GetId(), namespace.GetFqn()) +} + +func derivedActionNamespaces(derived *DerivedTargets) []*policy.Namespace { + if derived == nil { + return nil + } + + ordered := newNamespaceAccumulator() + for _, action := range derived.Actions { + if action == nil { + continue + } + for _, namespace := range action.Targets { + ordered.add(namespace) + } + } + + return ordered.slice() +} + +func derivedSubjectConditionSetNamespaces(derived *DerivedTargets) []*policy.Namespace { + if derived == nil { + return nil + } + + ordered := newNamespaceAccumulator() + for _, scs := range derived.SubjectConditionSets { + if scs == nil { + continue + } + for _, namespace := range scs.Targets { + ordered.add(namespace) + } + } + + return ordered.slice() +} + +func derivedSubjectMappingNamespaces(derived *DerivedTargets) []*policy.Namespace { + if derived == nil { + return nil + } + + ordered := newNamespaceAccumulator() + for _, mapping := range derived.SubjectMappings { + if mapping == nil { + continue + } + ordered.add(mapping.Target) + } + + return ordered.slice() +} + +func derivedRegisteredResourceNamespaces(derived *DerivedTargets) []*policy.Namespace { + if derived == nil { + return nil + } + + ordered := newNamespaceAccumulator() + for _, resource := range derived.RegisteredResources { + if resource == nil { + continue + } + ordered.add(resource.Target) + } + + return ordered.slice() +} + +func derivedObligationTriggerNamespaces(derived *DerivedTargets) []*policy.Namespace { + if derived == nil { + return nil + } + + ordered := newNamespaceAccumulator() + for _, trigger := range derived.ObligationTriggers { + if trigger == nil { + continue + } + ordered.add(trigger.Target) + } + + return ordered.slice() +} + +type namespaceAccumulator struct { + items []*policy.Namespace + seen map[string]struct{} +} + +func newNamespaceAccumulator() *namespaceAccumulator { + return &namespaceAccumulator{ + seen: make(map[string]struct{}), + } +} + +func (a *namespaceAccumulator) add(namespace *policy.Namespace) { + if a == nil || namespace == nil { + return + } + key := namespaceRefKey(namespace) + if key == "" { + return + } + if _, ok := a.seen[key]; ok { + return + } + a.seen[key] = struct{}{} + a.items = append(a.items, namespace) +} + +func (a *namespaceAccumulator) slice() []*policy.Namespace { + if a == nil { + return nil + } + + return append([]*policy.Namespace(nil), a.items...) +} + +type actionReferenceAccumulator struct { + items []*ActionReference + seen map[string]struct{} +} + +func newActionReferenceAccumulator() *actionReferenceAccumulator { + return &actionReferenceAccumulator{ + seen: make(map[string]struct{}), + } +} + +func (a *actionReferenceAccumulator) add(reference *ActionReference) { + if a == nil || reference == nil || reference.Kind == "" || reference.ID == "" { + return + } + key := actionReferenceKey(reference) + if key == "" { + return + } + if _, ok := a.seen[key]; ok { + return + } + a.seen[key] = struct{}{} + a.items = append(a.items, reference) +} + +func (a *actionReferenceAccumulator) slice() []*ActionReference { + if a == nil { + return nil + } + + return append([]*ActionReference(nil), a.items...) +} + +func actionReferenceKey(reference *ActionReference) string { + if reference == nil { + return "" + } + + return string(reference.Kind) + "|" + reference.ID + "|" + namespaceRefKey(reference.Namespace) +} diff --git a/otdfctl/migrations/namespacedpolicy/derived_test.go b/otdfctl/migrations/namespacedpolicy/derived_test.go new file mode 100644 index 0000000000..59f17f989a --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/derived_test.go @@ -0,0 +1,204 @@ +package namespacedpolicy + +import ( + "testing" + + "github.com/opentdf/platform/protocol/go/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeriveTargetsCollectsTargetsAndReferencesFromDependencies(t *testing.T) { + t.Parallel() + + namespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + retrieved := &Retrieved{ + Scopes: []Scope{ + ScopeActions, + ScopeSubjectConditionSets, + ScopeSubjectMappings, + ScopeRegisteredResources, + ScopeObligationTriggers, + }, + Candidates: Candidates{ + Actions: []*policy.Action{ + {Id: "action-1", Name: "decrypt"}, + }, + SubjectConditionSets: []*policy.SubjectConditionSet{ + {Id: "scs-1"}, + }, + SubjectMappings: []*policy.SubjectMapping{ + { + Id: "mapping-1", + AttributeValue: testAttributeValue( + "https://example.com/attr/classification/value/secret", + namespace, + ), + SubjectConditionSet: &policy.SubjectConditionSet{Id: "scs-1"}, + Actions: []*policy.Action{ + {Id: "action-1", Name: "decrypt"}, + }, + }, + }, + RegisteredResources: []*policy.RegisteredResource{ + testRegisteredResource( + "resource-1", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + "action-1", + "decrypt", + testAttributeValue("https://example.com/attr/classification/value/secret", namespace), + ), + ), + ), + }, + ObligationTriggers: []*policy.ObligationTrigger{ + { + Id: "trigger-1", + Action: &policy.Action{Id: "action-1", Name: "decrypt"}, + ObligationValue: &policy.ObligationValue{ + Id: "ov-1", + Fqn: "https://example.com/obl/notify/value/email", + Obligation: &policy.Obligation{Namespace: namespace}, + }, + }, + }, + }, + } + + derived, err := deriveTargets(retrieved, []*policy.Namespace{namespace}) + require.NoError(t, err) + + require.Len(t, derived.Actions, 1) + require.Len(t, derived.Actions[0].Targets, 1) + assert.Equal(t, namespace.GetId(), derived.Actions[0].Targets[0].GetId()) + assert.ElementsMatch(t, []string{ + "subject_mapping|mapping-1", + "registered_resource|resource-1", + "obligation_trigger|trigger-1", + }, actionReferenceKindsAndIDs(derived.Actions[0].References)) + + require.Len(t, derived.SubjectConditionSets, 1) + require.Len(t, derived.SubjectConditionSets[0].Targets, 1) + assert.Equal(t, namespace.GetId(), derived.SubjectConditionSets[0].Targets[0].GetId()) + require.Len(t, derived.SubjectMappings, 1) + assert.Equal(t, namespace.GetId(), derived.SubjectMappings[0].Target.GetId()) + require.Len(t, derived.RegisteredResources, 1) + assert.Equal(t, namespace.GetId(), derived.RegisteredResources[0].Target.GetId()) + require.Len(t, derived.ObligationTriggers, 1) + assert.Equal(t, namespace.GetId(), derived.ObligationTriggers[0].Target.GetId()) +} + +func TestDeriveTargetsFailsWhenSubjectMappingNamespaceCannotBeDerived(t *testing.T) { + t.Parallel() + + retrieved := &Retrieved{ + Scopes: []Scope{ScopeSubjectMappings}, + Candidates: Candidates{ + SubjectMappings: []*policy.SubjectMapping{ + { + Id: "mapping-1", + AttributeValue: &policy.Value{}, + }, + }, + }, + } + + derived, err := deriveTargets(retrieved, nil) + require.Error(t, err) + assert.Nil(t, derived) + assert.EqualError(t, err, `subject mapping "mapping-1": could not determine target namespace: empty namespace reference`) +} + +func TestDeriveTargetsKeepsRegisteredResourceNamespaceConflictUnresolved(t *testing.T) { + t.Parallel() + + leftNamespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + rightNamespace := &policy.Namespace{ + Id: "ns-2", + Fqn: "https://other.example.com", + } + retrieved := &Retrieved{ + Scopes: []Scope{ScopeRegisteredResources}, + Candidates: Candidates{ + RegisteredResources: []*policy.RegisteredResource{ + testRegisteredResource( + "resource-1", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + "action-1", + "decrypt", + testAttributeValue("https://example.com/attr/classification/value/secret", leftNamespace), + ), + testActionAttributeValue( + "action-2", + "encrypt", + testAttributeValue("https://other.example.com/attr/classification/value/secret", rightNamespace), + ), + ), + ), + }, + }, + } + + derived, err := deriveTargets(retrieved, []*policy.Namespace{leftNamespace, rightNamespace}) + require.NoError(t, err) + require.Len(t, derived.RegisteredResources, 1) + require.NotNil(t, derived.RegisteredResources[0].Unresolved) + assert.Equal(t, UnresolvedReasonRegisteredResourceConflictingNamespaces, derived.RegisteredResources[0].Unresolved.Reason) + assert.Equal( + t, + "could not determine target namespace: registered resource spans multiple target namespaces", + derived.RegisteredResources[0].Unresolved.Message, + ) + assert.Nil(t, derived.RegisteredResources[0].Target) +} + +func TestDeriveTargetsSkipsRegisteredResourceWithoutActionAttributeValues(t *testing.T) { + t.Parallel() + + namespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + retrieved := &Retrieved{ + Scopes: []Scope{ScopeRegisteredResources}, + Candidates: Candidates{ + RegisteredResources: []*policy.RegisteredResource{ + testRegisteredResource( + "resource-1", + "documents", + &policy.RegisteredResourceValue{ + Value: "prod", + }, + ), + }, + }, + } + + derived, err := deriveTargets(retrieved, []*policy.Namespace{namespace}) + require.NoError(t, err) + assert.Empty(t, derived.RegisteredResources) +} + +func actionReferenceKindsAndIDs(references []*ActionReference) []string { + keys := make([]string, 0, len(references)) + for _, reference := range references { + if reference == nil { + continue + } + keys = append(keys, string(reference.Kind)+"|"+reference.ID) + } + + return keys +} diff --git a/otdfctl/migrations/namespacedpolicy/execute.go b/otdfctl/migrations/namespacedpolicy/execute.go new file mode 100644 index 0000000000..78123c21d4 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/execute.go @@ -0,0 +1,146 @@ +package namespacedpolicy + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/registeredresources" + "github.com/opentdf/platform/protocol/go/policy/subjectmapping" +) + +var ( + ErrNilExecutorHandler = errors.New("executor handler is required") + ErrNilExecutionPlan = errors.New("execution plan is required") + ErrPlanNotExecutable = errors.New("plan is not executable") + ErrExecutionPhaseNotImplemented = errors.New("execution phase is not implemented") + ErrMissingExistingTarget = errors.New("missing existing target") + ErrMissingMigratedTarget = errors.New("missing migrated target") + ErrMissingActionTarget = errors.New("missing action target") + ErrMissingSubjectConditionSetTarget = errors.New("missing subject condition set target") + ErrTargetNamespaceRequired = errors.New("target namespace is required") + ErrMissingCreatedTargetID = errors.New("missing created target id") + ErrUnsupportedStatus = errors.New("unsupported status") +) + +const ( + migrationLabelMigratedFrom = "migrated_from" + migrationLabelRun = "migration_run" + unknownLabel = "" +) + +type ExecutorHandler interface { + CreateAction(ctx context.Context, name string, namespace string, metadata *common.MetadataMutable) (*policy.Action, error) + CreateSubjectConditionSet(ctx context.Context, ss []*policy.SubjectSet, metadata *common.MetadataMutable, namespace string) (*policy.SubjectConditionSet, error) + CreateNewSubjectMapping(ctx context.Context, attrValID string, actions []*policy.Action, existingSCSId string, newScs *subjectmapping.SubjectConditionSetCreate, metadata *common.MetadataMutable, namespace string) (*policy.SubjectMapping, error) + CreateObligationTrigger(ctx context.Context, attributeValue, action, obligationValue, clientID string, metadata *common.MetadataMutable) (*policy.ObligationTrigger, error) + CreateRegisteredResource(ctx context.Context, namespace string, name string, values []string, metadata *common.MetadataMutable) (*policy.RegisteredResource, error) + CreateRegisteredResourceValue(ctx context.Context, resourceID string, value string, actionAttributeValues []*registeredresources.ActionAttributeValue, metadata *common.MetadataMutable) (*policy.RegisteredResourceValue, error) +} + +type Executor struct { + handler ExecutorHandler + runID string + actionTargets map[string]map[string]*ActionTargetPlan + subjectConditionSets map[string]map[string]*SubjectConditionSetTargetPlan +} + +func NewExecutor(handler ExecutorHandler) (*Executor, error) { + if handler == nil { + return nil, ErrNilExecutorHandler + } + + return &Executor{ + handler: handler, + runID: uuid.NewString(), + actionTargets: make(map[string]map[string]*ActionTargetPlan), + subjectConditionSets: make(map[string]map[string]*SubjectConditionSetTargetPlan), + }, nil +} + +func (e *Executor) Execute(ctx context.Context, plan *Plan) error { + if err := e.validatePlan(plan); err != nil { + return err + } + + if err := e.executeActions(ctx, plan.Actions); err != nil { + return err + } + if err := e.executeSubjectConditionSets(ctx, plan.SubjectConditionSets); err != nil { + return err + } + if err := e.executeSubjectMappings(ctx, plan.SubjectMappings); err != nil { + return err + } + if err := e.executeRegisteredResources(ctx, plan.RegisteredResources); err != nil { + return err + } + if err := e.executeObligationTriggers(ctx, plan.ObligationTriggers); err != nil { + return err + } + + return nil +} + +func (e *Executor) validatePlan(plan *Plan) error { + if e == nil || e.handler == nil { + return ErrNilExecutorHandler + } + if plan == nil { + return ErrNilExecutionPlan + } + if plan.Unresolved != nil && hasUnresolved(*plan.Unresolved) { + return fmt.Errorf("%w: finalized plan contains unresolved entries", ErrPlanNotExecutable) + } + + return nil +} + +func metadataForCreate(sourceID string, sourceLabels map[string]string, runID string) *common.MetadataMutable { + labels := map[string]string{} + for key, value := range sourceLabels { + labels[key] = value + } + + labels[migrationLabelMigratedFrom] = sourceID + labels[migrationLabelRun] = runID + + return &common.MetadataMutable{ + Labels: labels, + } +} + +func metadataLabels(metadata *common.Metadata) map[string]string { + if metadata == nil { + return nil + } + + return metadata.GetLabels() +} + +func namespaceIdentifier(namespace *policy.Namespace) string { + if namespace == nil { + return "" + } + if id := strings.TrimSpace(namespace.GetId()); id != "" { + return id + } + return strings.TrimSpace(namespace.GetFqn()) +} + +func namespaceLabel(namespace *policy.Namespace) string { + if namespace == nil { + return unknownLabel + } + if fqn := strings.TrimSpace(namespace.GetFqn()); fqn != "" { + return fqn + } + if id := strings.TrimSpace(namespace.GetId()); id != "" { + return id + } + return unknownLabel +} diff --git a/otdfctl/migrations/namespacedpolicy/execute_test_helpers_test.go b/otdfctl/migrations/namespacedpolicy/execute_test_helpers_test.go new file mode 100644 index 0000000000..a7d09b27aa --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/execute_test_helpers_test.go @@ -0,0 +1,281 @@ +package namespacedpolicy + +import ( + "context" + "errors" + "fmt" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/registeredresources" + "github.com/opentdf/platform/protocol/go/policy/subjectmapping" +) + +var ( + errMissingMockActionResult = errors.New("missing mock action result") + errMissingMockSubjectConditionSetResult = errors.New("missing mock subject condition set result") + errMissingMockSubjectMappingResult = errors.New("missing mock subject mapping result") + errMissingMockObligationTriggerResult = errors.New("missing mock obligation trigger result") + errMissingMockRegisteredResourceResult = errors.New("missing mock registered resource result") + errMissingMockRegisteredResourceValue = errors.New("missing mock registered resource value result") +) + +type expectedError struct { + is error + message string +} + +func wantError(is error, format string, args ...any) *expectedError { + return &expectedError{ + is: is, + message: fmt.Sprintf("%s: %s", is, fmt.Sprintf(format, args...)), + } +} + +type mockExecutorHandler struct { + created map[string]map[string]*createdActionCall + results map[string]map[string]*policy.Action // ! Should be renamed to actionResults + errs map[string]map[string]error + createdSubjectConditions map[string]map[string]*createdSubjectConditionSetCall + subjectConditionSetResult map[string]map[string]*policy.SubjectConditionSet + subjectConditionSetErrs map[string]map[string]error + createdSubjectMappings map[string]map[string]*createdSubjectMappingCall + subjectMappingResults map[string]map[string]*policy.SubjectMapping + subjectMappingErrs map[string]map[string]error + createdObligationTriggers map[string]map[string]*createdObligationTriggerCall + obligationTriggerResult map[string]map[string]*policy.ObligationTrigger + obligationTriggerErrs map[string]map[string]error + createdRegisteredResources map[string]map[string]*createdRegisteredResourceCall + registeredResourceResult map[string]map[string]*policy.RegisteredResource + registeredResourceErrs map[string]map[string]error + createdRegisteredResourceValues map[string]map[string]*createdRegisteredResourceValueCall + registeredResourceValueResult map[string]map[string]*policy.RegisteredResourceValue + registeredResourceValueErrs map[string]map[string]error +} + +type createdActionCall struct { + Name string + Namespace string + Metadata *common.MetadataMutable +} + +type createdSubjectConditionSetCall struct { + SubjectSets []*policy.SubjectSet + Namespace string + Metadata *common.MetadataMutable +} + +type createdSubjectMappingCall struct { + AttributeValueID string + Actions []*policy.Action + ExistingSubjectConditionSet string + NewSubjectConditionSet *subjectmapping.SubjectConditionSetCreate + Namespace string + Metadata *common.MetadataMutable +} +type createdObligationTriggerCall struct { + AttributeValue string + Action string + ObligationValue string + ClientID string + Metadata *common.MetadataMutable +} + +type createdRegisteredResourceCall struct { + Name string + Namespace string + Values []string + Metadata *common.MetadataMutable +} + +type createdRegisteredResourceValueCall struct { + ResourceID string + Value string + ActionAttributeValues []*registeredresources.ActionAttributeValue + Metadata *common.MetadataMutable +} + +func (m *mockExecutorHandler) CreateAction(_ context.Context, name string, namespace string, metadata *common.MetadataMutable) (*policy.Action, error) { + if m.created == nil { + m.created = make(map[string]map[string]*createdActionCall) + } + if m.created[name] == nil { + m.created[name] = make(map[string]*createdActionCall) + } + + m.created[name][namespace] = &createdActionCall{ + Name: name, + Namespace: namespace, + Metadata: metadata, + } + + if m.errs != nil && m.errs[name] != nil { + if err := m.errs[name][namespace]; err != nil { + return nil, err + } + } + if m.results != nil && m.results[name] != nil { + if result := m.results[name][namespace]; result != nil { + return result, nil + } + } + + return nil, errMissingMockActionResult +} + +func (m *mockExecutorHandler) CreateSubjectConditionSet(_ context.Context, ss []*policy.SubjectSet, metadata *common.MetadataMutable, namespace string) (*policy.SubjectConditionSet, error) { + sourceID := metadata.GetLabels()[migrationLabelMigratedFrom] + + if m.createdSubjectConditions == nil { + m.createdSubjectConditions = make(map[string]map[string]*createdSubjectConditionSetCall) + } + if m.createdSubjectConditions[sourceID] == nil { + m.createdSubjectConditions[sourceID] = make(map[string]*createdSubjectConditionSetCall) + } + + m.createdSubjectConditions[sourceID][namespace] = &createdSubjectConditionSetCall{ + SubjectSets: ss, + Namespace: namespace, + Metadata: metadata, + } + + if m.subjectConditionSetErrs != nil && m.subjectConditionSetErrs[sourceID] != nil { + if err := m.subjectConditionSetErrs[sourceID][namespace]; err != nil { + return nil, err + } + } + if m.subjectConditionSetResult != nil && m.subjectConditionSetResult[sourceID] != nil { + if result := m.subjectConditionSetResult[sourceID][namespace]; result != nil { + return result, nil + } + } + + return nil, errMissingMockSubjectConditionSetResult +} + +func (m *mockExecutorHandler) CreateNewSubjectMapping(_ context.Context, attrValID string, actions []*policy.Action, existingSCSId string, newScs *subjectmapping.SubjectConditionSetCreate, metadata *common.MetadataMutable, namespace string) (*policy.SubjectMapping, error) { + sourceID := metadata.GetLabels()[migrationLabelMigratedFrom] + + if m.createdSubjectMappings == nil { + m.createdSubjectMappings = make(map[string]map[string]*createdSubjectMappingCall) + } + if m.createdSubjectMappings[sourceID] == nil { + m.createdSubjectMappings[sourceID] = make(map[string]*createdSubjectMappingCall) + } + + m.createdSubjectMappings[sourceID][namespace] = &createdSubjectMappingCall{ + AttributeValueID: attrValID, + Actions: actions, + ExistingSubjectConditionSet: existingSCSId, + NewSubjectConditionSet: newScs, + Namespace: namespace, + Metadata: metadata, + } + + if m.subjectMappingErrs != nil && m.subjectMappingErrs[sourceID] != nil { + if err := m.subjectMappingErrs[sourceID][namespace]; err != nil { + return nil, err + } + } + if m.subjectMappingResults != nil && m.subjectMappingResults[sourceID] != nil { + if result := m.subjectMappingResults[sourceID][namespace]; result != nil { + return result, nil + } + } + + return nil, errMissingMockSubjectMappingResult +} + +func (m *mockExecutorHandler) CreateObligationTrigger(_ context.Context, attributeValue, action, obligationValue, clientID string, metadata *common.MetadataMutable) (*policy.ObligationTrigger, error) { + sourceID := metadata.GetLabels()[migrationLabelMigratedFrom] + + if m.createdObligationTriggers == nil { + m.createdObligationTriggers = make(map[string]map[string]*createdObligationTriggerCall) + } + if m.createdObligationTriggers[sourceID] == nil { + m.createdObligationTriggers[sourceID] = make(map[string]*createdObligationTriggerCall) + } + + m.createdObligationTriggers[sourceID][action] = &createdObligationTriggerCall{ + AttributeValue: attributeValue, + Action: action, + ObligationValue: obligationValue, + ClientID: clientID, + Metadata: metadata, + } + + if m.obligationTriggerErrs != nil && m.obligationTriggerErrs[sourceID] != nil { + if err := m.obligationTriggerErrs[sourceID][action]; err != nil { + return nil, err + } + } + if m.obligationTriggerResult != nil && m.obligationTriggerResult[sourceID] != nil { + if result := m.obligationTriggerResult[sourceID][action]; result != nil { + return result, nil + } + } + + return nil, errMissingMockObligationTriggerResult +} + +func (m *mockExecutorHandler) CreateRegisteredResource(_ context.Context, namespace string, name string, values []string, metadata *common.MetadataMutable) (*policy.RegisteredResource, error) { + sourceID := metadata.GetLabels()[migrationLabelMigratedFrom] + + if m.createdRegisteredResources == nil { + m.createdRegisteredResources = make(map[string]map[string]*createdRegisteredResourceCall) + } + if m.createdRegisteredResources[sourceID] == nil { + m.createdRegisteredResources[sourceID] = make(map[string]*createdRegisteredResourceCall) + } + + m.createdRegisteredResources[sourceID][namespace] = &createdRegisteredResourceCall{ + Name: name, + Namespace: namespace, + Values: values, + Metadata: metadata, + } + + if m.registeredResourceErrs != nil && m.registeredResourceErrs[sourceID] != nil { + if err := m.registeredResourceErrs[sourceID][namespace]; err != nil { + return nil, err + } + } + if m.registeredResourceResult != nil && m.registeredResourceResult[sourceID] != nil { + if result := m.registeredResourceResult[sourceID][namespace]; result != nil { + return result, nil + } + } + + return nil, errMissingMockRegisteredResourceResult +} + +func (m *mockExecutorHandler) CreateRegisteredResourceValue(_ context.Context, resourceID string, value string, actionAttributeValues []*registeredresources.ActionAttributeValue, metadata *common.MetadataMutable) (*policy.RegisteredResourceValue, error) { + sourceID := metadata.GetLabels()[migrationLabelMigratedFrom] + + if m.createdRegisteredResourceValues == nil { + m.createdRegisteredResourceValues = make(map[string]map[string]*createdRegisteredResourceValueCall) + } + if m.createdRegisteredResourceValues[sourceID] == nil { + m.createdRegisteredResourceValues[sourceID] = make(map[string]*createdRegisteredResourceValueCall) + } + + m.createdRegisteredResourceValues[sourceID][resourceID] = &createdRegisteredResourceValueCall{ + ResourceID: resourceID, + Value: value, + ActionAttributeValues: actionAttributeValues, + Metadata: metadata, + } + + if m.registeredResourceValueErrs != nil && m.registeredResourceValueErrs[sourceID] != nil { + if err := m.registeredResourceValueErrs[sourceID][resourceID]; err != nil { + return nil, err + } + } + if m.registeredResourceValueResult != nil && m.registeredResourceValueResult[sourceID] != nil { + if result := m.registeredResourceValueResult[sourceID][resourceID]; result != nil { + return result, nil + } + } + + return nil, errMissingMockRegisteredResourceValue +} diff --git a/otdfctl/migrations/namespacedpolicy/finalize_plan.go b/otdfctl/migrations/namespacedpolicy/finalize_plan.go new file mode 100644 index 0000000000..fbc5b1c3e4 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/finalize_plan.go @@ -0,0 +1,509 @@ +package namespacedpolicy + +import ( + "errors" + + "github.com/opentdf/platform/protocol/go/policy" +) + +var ErrNilResolvedTargets = errors.New("planner resolved state is required") + +const unusedActionReason = "action is not referenced by any subject mapping, registered resource, or obligation trigger" + +// finalizePlan converts the fully resolved graph into the current Plan shape. +// This is the last planner stage before artifact building/execution wiring. +func finalizePlan(resolved *ResolvedTargets, namespaces []*policy.Namespace) (*Plan, error) { + if resolved == nil { + return nil, ErrNilResolvedTargets + } + + scopes, err := normalizeScopes(resolved.Scopes) + if err != nil { + return nil, err + } + + finalizer := newPlanFinalizer(resolved, namespaces) + + if scopes.requiresActions() { + for _, action := range resolved.Actions { + finalizer.addResolvedAction(action) + } + } + + if scopes.requiresSubjectConditionSets() { + for _, scs := range resolved.SubjectConditionSets { + finalizer.addResolvedSubjectConditionSet(scs) + } + } + + if scopes.has(ScopeSubjectMappings) { + for _, mapping := range resolved.SubjectMappings { + finalizer.addResolvedSubjectMapping(mapping) + } + } + + if scopes.has(ScopeRegisteredResources) { + for _, resource := range resolved.RegisteredResources { + finalizer.addResolvedRegisteredResource(resource) + } + } + + if scopes.has(ScopeObligationTriggers) { + for _, trigger := range resolved.ObligationTriggers { + finalizer.addResolvedObligationTrigger(trigger) + } + } + + return finalizer.build(), nil +} + +// planFinalizer folds resolved placements into an executable plan that +// preserves per-target status and dependency bindings for downstream creates. +type planFinalizer struct { + resolved *ResolvedTargets + namespaces []*policy.Namespace + namespacePlansByID map[string]*NamespacePlan + actions []*ActionPlan + subjectConditionSets []*SubjectConditionSetPlan + subjectMappings []*SubjectMappingPlan + registeredResources []*RegisteredResourcePlan + obligationTriggers []*ObligationTriggerPlan + actionTargetsByKey map[string]*ActionTargetPlan + scsTargetsByKey map[string]*SubjectConditionSetTargetPlan + unused UnusedPlan + unresolved UnresolvedPlan +} + +func newPlanFinalizer(resolved *ResolvedTargets, namespaces []*policy.Namespace) *planFinalizer { + return &planFinalizer{ + resolved: resolved, + namespaces: namespaces, + namespacePlansByID: make(map[string]*NamespacePlan), + actionTargetsByKey: make(map[string]*ActionTargetPlan), + scsTargetsByKey: make(map[string]*SubjectConditionSetTargetPlan), + } +} + +func (f *planFinalizer) build() *Plan { + plan := &Plan{ + Scopes: append([]Scope(nil), f.resolved.Scopes...), + Namespaces: make([]*NamespacePlan, 0, len(f.namespacePlansByID)), + Actions: append([]*ActionPlan(nil), f.actions...), + SubjectConditionSets: append([]*SubjectConditionSetPlan(nil), f.subjectConditionSets...), + SubjectMappings: append([]*SubjectMappingPlan(nil), f.subjectMappings...), + RegisteredResources: append([]*RegisteredResourcePlan(nil), f.registeredResources...), + ObligationTriggers: append([]*ObligationTriggerPlan(nil), f.obligationTriggers...), + } + + for _, namespace := range f.namespaces { + if namespace == nil || namespace.GetId() == "" { + continue + } + if namespacePlan, ok := f.namespacePlansByID[namespace.GetId()]; ok { + plan.Namespaces = append(plan.Namespaces, namespacePlan) + } + } + + if hasUnused(f.unused) { + plan.Unused = &f.unused + } + + if hasUnresolved(f.unresolved) { + plan.Unresolved = &f.unresolved + } + + return plan +} + +func (f *planFinalizer) addResolvedAction(item *ResolvedAction) { + if item == nil || item.Source == nil { + return + } + + if len(item.Results) == 0 && len(item.References) == 0 { + f.addUnusedAction(item.Source, item.References, unusedActionReason) + return + } + + actionPlan := &ActionPlan{ + Source: item.Source, + References: append([]*ActionReference(nil), item.References...), + Targets: make([]*ActionTargetPlan, 0, len(item.Results)), + } + + for _, result := range item.Results { + target := newActionTargetPlan(result) + if target == nil { + continue + } + actionPlan.Targets = append(actionPlan.Targets, target) + f.storeActionTarget(item.Source.GetId(), target) + + f.addNamespacePlacement(target.Namespace, ScopeActions, item.Source.GetId()) + } + + f.actions = append(f.actions, actionPlan) +} + +func (f *planFinalizer) addResolvedSubjectConditionSet(item *ResolvedSubjectConditionSet) { + if item == nil || item.Source == nil { + return + } + + scsPlan := &SubjectConditionSetPlan{ + Source: item.Source, + Targets: make([]*SubjectConditionSetTargetPlan, 0, len(item.Results)), + } + + for _, result := range item.Results { + target := newSubjectConditionSetTargetPlan(result) + if target == nil { + continue + } + scsPlan.Targets = append(scsPlan.Targets, target) + f.storeSubjectConditionSetTarget(item.Source.GetId(), target) + + f.addNamespacePlacement(target.Namespace, ScopeSubjectConditionSets, item.Source.GetId()) + } + + f.subjectConditionSets = append(f.subjectConditionSets, scsPlan) +} + +func (f *planFinalizer) addResolvedSubjectMapping(item *ResolvedSubjectMapping) { + if item == nil || item.Source == nil { + return + } + + mappingPlan := &SubjectMappingPlan{Source: item.Source} + + target := f.newSubjectMappingTarget(item) + if target != nil { + mappingPlan.Targets = append(mappingPlan.Targets, target) + f.addNamespacePlacement(target.Namespace, ScopeSubjectMappings, item.Source.GetId()) + } + + f.subjectMappings = append(f.subjectMappings, mappingPlan) +} + +func (f *planFinalizer) addResolvedRegisteredResource(item *ResolvedRegisteredResource) { + if item == nil || item.Source == nil { + return + } + + resourcePlan := &RegisteredResourcePlan{Source: item.Source} + if item.Unresolved != nil { + resourcePlan.Unresolved = item.Unresolved.Message + } + + target := f.newRegisteredResourceTarget(item) + if target != nil { + resourcePlan.Targets = append(resourcePlan.Targets, target) + if target.Status == TargetStatusUnresolved { + f.addRegisteredResourceIssue(item.Source, target.Namespace, target.Reason) + } else { + f.addNamespacePlacement(target.Namespace, ScopeRegisteredResources, item.Source.GetId()) + } + } else if item.Unresolved != nil { + f.addRegisteredResourceIssue(item.Source, item.Namespace, item.Unresolved.Message) + } + + f.registeredResources = append(f.registeredResources, resourcePlan) +} + +func (f *planFinalizer) addResolvedObligationTrigger(item *ResolvedObligationTrigger) { + if item == nil || item.Source == nil { + return + } + + triggerPlan := &ObligationTriggerPlan{Source: item.Source} + + target := f.newObligationTriggerTarget(item) + if target != nil { + triggerPlan.Targets = append(triggerPlan.Targets, target) + f.addNamespacePlacement(target.Namespace, ScopeObligationTriggers, item.Source.GetId()) + } + + f.obligationTriggers = append(f.obligationTriggers, triggerPlan) +} + +func (f *planFinalizer) namespacePlan(namespace *policy.Namespace) *NamespacePlan { + if namespace == nil || namespace.GetId() == "" { + return nil + } + + namespacePlan, ok := f.namespacePlansByID[namespace.GetId()] + if ok { + return namespacePlan + } + + namespacePlan = &NamespacePlan{ + Namespace: namespace, + } + f.namespacePlansByID[namespace.GetId()] = namespacePlan + return namespacePlan +} + +func (f *planFinalizer) addNamespacePlacement(namespace *policy.Namespace, scope Scope, sourceID string) { + if namespace == nil || namespace.GetId() == "" || sourceID == "" { + return + } + + namespacePlan := f.namespacePlan(namespace) + if namespacePlan == nil { + return + } + + switch scope { + case ScopeActions: + namespacePlan.Actions = appendUniqueString(namespacePlan.Actions, sourceID) + case ScopeSubjectConditionSets: + namespacePlan.SubjectConditionSets = appendUniqueString(namespacePlan.SubjectConditionSets, sourceID) + case ScopeSubjectMappings: + namespacePlan.SubjectMappings = appendUniqueString(namespacePlan.SubjectMappings, sourceID) + case ScopeRegisteredResources: + namespacePlan.RegisteredResources = appendUniqueString(namespacePlan.RegisteredResources, sourceID) + case ScopeObligationTriggers: + namespacePlan.ObligationTriggers = appendUniqueString(namespacePlan.ObligationTriggers, sourceID) + } +} + +func (f *planFinalizer) storeActionTarget(sourceID string, target *ActionTargetPlan) { + if sourceID == "" || target == nil || target.Namespace == nil || target.Namespace.GetId() == "" { + return + } + f.actionTargetsByKey[resolvedResultKey(sourceID, target.Namespace.GetId())] = target +} + +func (f *planFinalizer) storeSubjectConditionSetTarget(sourceID string, target *SubjectConditionSetTargetPlan) { + if sourceID == "" || target == nil || target.Namespace == nil || target.Namespace.GetId() == "" { + return + } + f.scsTargetsByKey[resolvedResultKey(sourceID, target.Namespace.GetId())] = target +} + +func (f *planFinalizer) newSubjectMappingTarget(item *ResolvedSubjectMapping) *SubjectMappingTargetPlan { + if item == nil || item.Namespace == nil { + return nil + } + + target := &SubjectMappingTargetPlan{ + Namespace: item.Namespace, + Actions: make([]*ActionBinding, 0, len(item.Source.GetActions())), + } + + switch { + case item.AlreadyMigrated != nil: + target.Status = TargetStatusAlreadyMigrated + target.Existing = item.AlreadyMigrated + case item.NeedsCreate: + target.Status = TargetStatusCreate + default: + return nil + } + + for _, action := range item.Source.GetActions() { + target.Actions = append(target.Actions, f.actionBinding(action.GetId(), item.Namespace)) + } + target.SubjectConditionSet = f.subjectConditionSetBinding(item.Source.GetSubjectConditionSet().GetId(), item.Namespace) + + return target +} + +func (f *planFinalizer) newRegisteredResourceTarget(item *ResolvedRegisteredResource) *RegisteredResourceTargetPlan { + if item == nil || item.Namespace == nil { + return nil + } + + target := &RegisteredResourceTargetPlan{ + Namespace: item.Namespace, + Values: make([]*RegisteredResourceValuePlan, 0, len(item.Source.GetValues())), + } + + switch { + case item.AlreadyMigrated != nil: + target.Status = TargetStatusAlreadyMigrated + target.Existing = item.AlreadyMigrated + case item.NeedsCreate: + target.Status = TargetStatusCreate + default: + return nil + } + + for _, value := range item.Source.GetValues() { + valuePlan := &RegisteredResourceValuePlan{ + Source: value, + ActionBindings: make([]*RegisteredResourceActionBinding, 0, len(value.GetActionAttributeValues())), + } + for _, aav := range value.GetActionAttributeValues() { + if aav == nil { + continue + } + valuePlan.ActionBindings = append(valuePlan.ActionBindings, &RegisteredResourceActionBinding{ + SourceActionID: aav.GetAction().GetId(), + AttributeValue: aav.GetAttributeValue(), + ActionTargetRef: f.actionBinding( + aav.GetAction().GetId(), + item.Namespace, + ), + }) + } + target.Values = append(target.Values, valuePlan) + } + + return target +} + +func (f *planFinalizer) newObligationTriggerTarget(item *ResolvedObligationTrigger) *ObligationTriggerTargetPlan { + if item == nil || item.Namespace == nil { + return nil + } + + target := &ObligationTriggerTargetPlan{ + Namespace: item.Namespace, + } + switch { + case item.AlreadyMigrated != nil: + target.Status = TargetStatusAlreadyMigrated + target.Existing = item.AlreadyMigrated + case item.NeedsCreate: + target.Status = TargetStatusCreate + default: + return nil + } + target.Action = f.actionBinding(item.Source.GetAction().GetId(), item.Namespace) + + return target +} + +func (f *planFinalizer) actionBinding(sourceID string, namespace *policy.Namespace) *ActionBinding { + if sourceID == "" || namespace == nil { + return nil + } + + target := f.actionTargetsByKey[resolvedResultKey(sourceID, namespace.GetId())] + if target == nil { + return &ActionBinding{ + SourceID: sourceID, + Namespace: namespace, + Status: TargetStatusUnresolved, + Reason: "action target is not available in the finalized plan", + } + } + + return &ActionBinding{ + SourceID: sourceID, + Namespace: namespace, + Status: target.Status, + TargetID: target.TargetID(), + Reason: target.Reason, + } +} + +func (f *planFinalizer) subjectConditionSetBinding(sourceID string, namespace *policy.Namespace) *SubjectConditionSetBinding { + if sourceID == "" || namespace == nil { + return nil + } + + target := f.scsTargetsByKey[resolvedResultKey(sourceID, namespace.GetId())] + if target == nil { + return &SubjectConditionSetBinding{ + SourceID: sourceID, + Namespace: namespace, + Status: TargetStatusUnresolved, + Reason: "subject condition set target is not available in the finalized plan", + } + } + + return &SubjectConditionSetBinding{ + SourceID: sourceID, + Namespace: namespace, + Status: target.Status, + TargetID: target.TargetID(), + Reason: target.Reason, + } +} + +func (f *planFinalizer) addUnusedAction(action *policy.Action, references []*ActionReference, reason string) { + if action == nil || reason == "" { + return + } + for _, unused := range f.unused.Actions { + if unused != nil && unused.Source != nil && unused.Source.GetId() == action.GetId() && unused.Reason == reason { + return + } + } + f.unused.Actions = append(f.unused.Actions, &UnusedAction{ + Source: action, + References: append([]*ActionReference(nil), references...), + Reason: reason, + }) +} + +func (f *planFinalizer) addRegisteredResourceIssue(resource *policy.RegisteredResource, namespace *policy.Namespace, reason string) { + if resource == nil || reason == "" { + return + } + for _, issue := range f.unresolved.RegisteredResources { + if issue != nil && issue.Resource != nil && + issue.Resource.GetId() == resource.GetId() && + sameNamespace(issue.Namespace, namespace) && + issue.Reason == reason { + return + } + } + f.unresolved.RegisteredResources = append(f.unresolved.RegisteredResources, &RegisteredResourceIssue{ + Resource: resource, + Namespace: namespace, + Reason: reason, + }) +} + +func newActionTargetPlan(result *ResolvedActionResult) *ActionTargetPlan { + if result == nil || result.Namespace == nil { + return nil + } + + target := &ActionTargetPlan{Namespace: result.Namespace} + switch { + case result.AlreadyMigrated != nil: + target.Status = TargetStatusAlreadyMigrated + target.Existing = result.AlreadyMigrated + case result.ExistingStandard != nil: + target.Status = TargetStatusExistingStandard + target.Existing = result.ExistingStandard + case result.NeedsCreate: + target.Status = TargetStatusCreate + default: + return nil + } + + return target +} + +func newSubjectConditionSetTargetPlan(result *ResolvedSubjectConditionSetResult) *SubjectConditionSetTargetPlan { + if result == nil || result.Namespace == nil { + return nil + } + + target := &SubjectConditionSetTargetPlan{Namespace: result.Namespace} + switch { + case result.AlreadyMigrated != nil: + target.Status = TargetStatusAlreadyMigrated + target.Existing = result.AlreadyMigrated + case result.NeedsCreate: + target.Status = TargetStatusCreate + default: + return nil + } + + return target +} + +func appendUniqueString(items []string, value string) []string { + for _, item := range items { + if item == value { + return items + } + } + return append(items, value) +} diff --git a/otdfctl/migrations/namespacedpolicy/finalize_plan_test.go b/otdfctl/migrations/namespacedpolicy/finalize_plan_test.go new file mode 100644 index 0000000000..3de1af4ee2 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/finalize_plan_test.go @@ -0,0 +1,115 @@ +package namespacedpolicy + +import ( + "testing" + + "github.com/opentdf/platform/protocol/go/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFinalizePlanBuildsBindingsForDependentObjects(t *testing.T) { + t.Parallel() + + namespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + + plan, err := finalizePlan(&ResolvedTargets{ + Scopes: []Scope{ + ScopeActions, + ScopeSubjectConditionSets, + ScopeSubjectMappings, + ScopeRegisteredResources, + ScopeObligationTriggers, + }, + Actions: []*ResolvedAction{ + { + Source: &policy.Action{Id: "action-1", Name: "decrypt"}, + Results: []*ResolvedActionResult{ + {Namespace: namespace, NeedsCreate: true}, + }, + }, + }, + SubjectConditionSets: []*ResolvedSubjectConditionSet{ + { + Source: &policy.SubjectConditionSet{Id: "scs-1"}, + Results: []*ResolvedSubjectConditionSetResult{ + { + Namespace: namespace, + AlreadyMigrated: &policy.SubjectConditionSet{Id: "scs-target"}, + }, + }, + }, + }, + SubjectMappings: []*ResolvedSubjectMapping{ + { + Source: &policy.SubjectMapping{ + Id: "mapping-1", + Actions: []*policy.Action{ + {Id: "action-1", Name: "decrypt"}, + }, + SubjectConditionSet: &policy.SubjectConditionSet{Id: "scs-1"}, + }, + Namespace: namespace, + NeedsCreate: true, + }, + }, + RegisteredResources: []*ResolvedRegisteredResource{ + { + Source: testRegisteredResource( + "resource-1", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + "action-1", + "decrypt", + testAttributeValue("https://example.com/attr/classification/value/secret", nil), + ), + ), + ), + Namespace: namespace, + NeedsCreate: true, + }, + }, + ObligationTriggers: []*ResolvedObligationTrigger{ + { + Source: &policy.ObligationTrigger{ + Id: "trigger-1", + Action: &policy.Action{Id: "action-1", Name: "decrypt"}, + }, + Namespace: namespace, + NeedsCreate: true, + }, + }, + }, []*policy.Namespace{namespace}) + require.NoError(t, err) + + require.Len(t, plan.SubjectMappings, 1) + require.Len(t, plan.SubjectMappings[0].Targets, 1) + assert.Equal(t, TargetStatusCreate, plan.SubjectMappings[0].Targets[0].Status) + require.Len(t, plan.SubjectMappings[0].Targets[0].Actions, 1) + assert.Equal(t, TargetStatusCreate, plan.SubjectMappings[0].Targets[0].Actions[0].Status) + assert.Equal(t, "action-1", plan.SubjectMappings[0].Targets[0].Actions[0].SourceID) + require.NotNil(t, plan.SubjectMappings[0].Targets[0].SubjectConditionSet) + assert.Equal(t, TargetStatusAlreadyMigrated, plan.SubjectMappings[0].Targets[0].SubjectConditionSet.Status) + assert.Equal(t, "scs-target", plan.SubjectMappings[0].Targets[0].SubjectConditionSet.TargetID) + + require.Len(t, plan.RegisteredResources, 1) + require.Len(t, plan.RegisteredResources[0].Targets, 1) + require.Len(t, plan.RegisteredResources[0].Targets[0].Values, 1) + require.Len(t, plan.RegisteredResources[0].Targets[0].Values[0].ActionBindings, 1) + assert.Equal(t, TargetStatusCreate, plan.RegisteredResources[0].Targets[0].Values[0].ActionBindings[0].ActionTargetRef.Status) + + require.Len(t, plan.ObligationTriggers, 1) + require.Len(t, plan.ObligationTriggers[0].Targets, 1) + require.NotNil(t, plan.ObligationTriggers[0].Targets[0].Action) + assert.Equal(t, TargetStatusCreate, plan.ObligationTriggers[0].Targets[0].Action.Status) + + require.Len(t, plan.Namespaces, 1) + assert.Equal(t, []string{"mapping-1"}, plan.Namespaces[0].SubjectMappings) + assert.Equal(t, []string{"resource-1"}, plan.Namespaces[0].RegisteredResources) + assert.Equal(t, []string{"trigger-1"}, plan.Namespaces[0].ObligationTriggers) +} diff --git a/otdfctl/migrations/namespacedpolicy/interactive_prompt.go b/otdfctl/migrations/namespacedpolicy/interactive_prompt.go new file mode 100644 index 0000000000..7d89e61d5f --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/interactive_prompt.go @@ -0,0 +1,124 @@ +package namespacedpolicy + +import ( + "context" + "errors" + "strings" + + "github.com/charmbracelet/huh" +) + +var ErrInteractiveReviewAborted = errors.New("interactive review aborted by user") + +// ConfirmPrompt is a generic confirmation prompt for planner-owned review flows. +type ConfirmPrompt struct { + Title string + Description []string + ConfirmLabel string + CancelLabel string +} + +// PromptOption is one selectable value in a generic interactive prompt. +type PromptOption struct { + Label string + Value string + Description string +} + +// SelectPrompt is a generic single-select prompt for planner-owned review flows. +type SelectPrompt struct { + Title string + Description []string + Options []PromptOption +} + +// InteractivePrompter abstracts the concrete prompt implementation so review +// orchestration stays planner-owned and testable. +type InteractivePrompter interface { + Confirm(context.Context, ConfirmPrompt) error + Select(context.Context, SelectPrompt) (string, error) +} + +// HuhPrompter implements InteractivePrompter using charmbracelet/huh forms. +type HuhPrompter struct{} + +func (p *HuhPrompter) Confirm(ctx context.Context, prompt ConfirmPrompt) error { + confirmLabel := strings.TrimSpace(prompt.ConfirmLabel) + if confirmLabel == "" { + confirmLabel = "Continue" + } + + cancelLabel := strings.TrimSpace(prompt.CancelLabel) + if cancelLabel == "" { + cancelLabel = "Abort" + } + + var choice bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title(strings.TrimSpace(prompt.Title)). + Description(promptDescription(prompt.Description)). + Affirmative(confirmLabel). + Negative(cancelLabel). + Value(&choice), + ), + ) + + if err := form.RunWithContext(ctx); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return ErrInteractiveReviewAborted + } + return err + } + + if !choice { + return ErrInteractiveReviewAborted + } + + return nil +} + +func (p *HuhPrompter) Select(ctx context.Context, prompt SelectPrompt) (string, error) { + options := make([]huh.Option[string], 0, len(prompt.Options)) + for _, option := range prompt.Options { + label := option.Label + if description := strings.TrimSpace(option.Description); description != "" { + label += " - " + description + } + options = append(options, huh.NewOption(label, option.Value)) + } + + var choice string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(strings.TrimSpace(prompt.Title)). + Description(promptDescription(prompt.Description)). + Options(options...). + Value(&choice), + ), + ) + + if err := form.RunWithContext(ctx); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return "", ErrInteractiveReviewAborted + } + return "", err + } + + return choice, nil +} + +func promptDescription(description []string) string { + lines := make([]string, 0, len(description)) + for _, line := range description { + line = strings.TrimSpace(line) + if line == "" { + continue + } + lines = append(lines, line) + } + + return strings.Join(lines, "\n") +} diff --git a/otdfctl/migrations/namespacedpolicy/interactive_review.go b/otdfctl/migrations/namespacedpolicy/interactive_review.go new file mode 100644 index 0000000000..9572078d4c --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/interactive_review.go @@ -0,0 +1,477 @@ +package namespacedpolicy + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/opentdf/platform/protocol/go/policy" + "google.golang.org/protobuf/proto" +) + +const ( + interactiveReviewAbortOption = "__abort_interactive_review__" + minimumRegisteredResourceReviewNamespaces = 2 +) + +var ErrNilInteractiveReviewHandler = errors.New("interactive review handler is required") + +// InteractiveReviewer owns planner-time interactive review. It mutates +// resolved planner state before finalization when interactive review is enabled. +type InteractiveReviewer interface { + Review(context.Context, *ResolvedTargets, []*policy.Namespace) error +} + +// HuhInteractiveReviewer is the planner-owned interactive review entrypoint for +// `migrate namespaced-policy --interactive`. +// +// The only actionable planner-time review currently supported is resolving +// registered resources whose action-attribute-values span multiple namespaces. +type HuhInteractiveReviewer struct { + handler PolicyClient + prompter InteractivePrompter + pageSize int32 +} + +func NewHuhInteractiveReviewer(handler PolicyClient, prompter InteractivePrompter) *HuhInteractiveReviewer { + return &HuhInteractiveReviewer{ + handler: handler, + prompter: prompter, + pageSize: defaultPlannerPageSize, + } +} + +func (r *HuhInteractiveReviewer) Review(ctx context.Context, resolved *ResolvedTargets, namespaces []*policy.Namespace) error { + if resolved == nil { + return nil + } + + var retriever *Retriever + namespaceCache := make(map[string]*interactiveReviewNamespaceState) + for _, resource := range resolved.RegisteredResources { + if !isConflictingRegisteredResource(resource) { + continue + } + if retriever == nil { + var err error + retriever, err = r.retriever() + if err != nil { + return err + } + } + if err := r.reviewRegisteredResource(ctx, resolved, resource, namespaces, retriever, namespaceCache); err != nil { + return err + } + } + + return nil +} + +func (r *HuhInteractiveReviewer) reviewRegisteredResource( + ctx context.Context, + resolved *ResolvedTargets, + resource *ResolvedRegisteredResource, + namespaces []*policy.Namespace, + retriever *Retriever, + namespaceCache map[string]*interactiveReviewNamespaceState, +) error { + if resource == nil || resource.Source == nil { + return nil + } + + candidates, err := registeredResourceCandidateNamespaces(resource.Source, namespaces) + if err != nil { + return fmt.Errorf("registered resource %q: %w", resource.Source.GetId(), err) + } + + selected, err := r.resolvePrompter().Select(ctx, registeredResourceConflictPrompt(resource.Source, candidates)) + if err != nil { + return err + } + // registeredResourceConflictPrompt appends interactiveReviewAbortOption to SelectPrompt.Options, + // but selectedNamespace only searches candidates, so this short-circuit must remain in place. + if selected == interactiveReviewAbortOption { + return ErrInteractiveReviewAborted + } + + chosen := selectedNamespace(candidates, selected) + if chosen == nil { + return fmt.Errorf("registered resource %q: invalid namespace choice %q", resource.Source.GetId(), selected) + } + + filtered, err := filterRegisteredResourceToNamespace(resource.Source, chosen) + if err != nil { + return fmt.Errorf("registered resource %q: %w", resource.Source.GetId(), err) + } + + namespaceState, err := reviewNamespaceState(ctx, retriever, chosen, namespaceCache) + if err != nil { + return fmt.Errorf("registered resource %q: %w", resource.Source.GetId(), err) + } + + resource.Source = filtered + resource.Namespace = chosen + // Reset planner state before re-resolving against registeredResources[chosen.GetId()] so AlreadyMigrated/NeedsCreate matches resolver.resolveRegisteredResource semantics for the chosen namespace. + resource.Unresolved = nil + resource.AlreadyMigrated = nil + resource.NeedsCreate = false + + existing, found, err := resolveExistingRegisteredResource(filtered, namespaceState.registeredResources) + switch { + case found: + resource.AlreadyMigrated = existing + return nil + case err != nil: + return fmt.Errorf("registered resource %q in namespace %q: %w", filtered.GetId(), chosen.GetId(), err) + default: + resource.NeedsCreate = true + } + + for _, value := range filtered.GetValues() { + for _, aav := range value.GetActionAttributeValues() { + if err := ensureRegisteredResourceActionResolution(resolved, resource.Source.GetId(), chosen, aav.GetAction(), namespaceState.actionResolver); err != nil { + return fmt.Errorf("registered resource %q: %w", resource.Source.GetId(), err) + } + } + } + + return nil +} + +type interactiveReviewNamespaceState struct { + actionResolver *resolver + registeredResources []*policy.RegisteredResource +} + +func reviewNamespaceState( + ctx context.Context, + retriever *Retriever, + chosen *policy.Namespace, + namespaceCache map[string]*interactiveReviewNamespaceState, +) (*interactiveReviewNamespaceState, error) { + if chosen == nil { + return nil, fmt.Errorf("%w: empty namespace reference", ErrUndeterminedTargetMapping) + } + if retriever == nil { + return nil, ErrNilInteractiveReviewHandler + } + + key := chosen.GetId() + if state, ok := namespaceCache[key]; ok { + return state, nil + } + + customActions, standardActions, err := retriever.listActionsForNamespaces(ctx, []*policy.Namespace{chosen}) + if err != nil { + return nil, err + } + + registeredResources, err := retriever.listRegisteredResourcesForNamespaces(ctx, []*policy.Namespace{chosen}) + if err != nil { + return nil, err + } + + state := &interactiveReviewNamespaceState{ + actionResolver: &resolver{ + existing: &ExistingTargets{ + CustomActions: customActions, + StandardActions: standardActions, + }, + actionResultsByKey: make(map[string]*ResolvedActionResult), + scsResultsByKey: make(map[string]*ResolvedSubjectConditionSetResult), + }, + registeredResources: registeredResources[chosen.GetId()], + } + namespaceCache[key] = state + return state, nil +} + +func (r *HuhInteractiveReviewer) resolvePrompter() InteractivePrompter { + if r != nil && r.prompter != nil { + return r.prompter + } + + return &HuhPrompter{} +} + +func (r *HuhInteractiveReviewer) retriever() (*Retriever, error) { + if r == nil || r.handler == nil { + return nil, ErrNilInteractiveReviewHandler + } + + pageSize := r.pageSize + if pageSize <= 0 { + pageSize = defaultPlannerPageSize + } + + return newRetriever(r.handler, pageSize), nil +} + +func isConflictingRegisteredResource(resource *ResolvedRegisteredResource) bool { + if resource == nil || resource.Unresolved == nil { + return false + } + + return resource.Unresolved.Reason == UnresolvedReasonRegisteredResourceConflictingNamespaces +} + +func registeredResourceCandidateNamespaces(resource *policy.RegisteredResource, namespaces []*policy.Namespace) ([]*policy.Namespace, error) { + if resource == nil { + return nil, fmt.Errorf("%w: registered resource is empty", ErrUndeterminedTargetMapping) + } + + deriver := newTargetDeriver(namespaces) + ordered := newNamespaceAccumulator() + + for _, value := range resource.GetValues() { + for _, aav := range value.GetActionAttributeValues() { + namespace, err := deriver.resolveNamespace(namespaceFromAttributeValue(aav.GetAttributeValue())) + if err != nil { + return nil, err + } + ordered.add(namespace) + } + } + + candidates := ordered.slice() + if len(candidates) < minimumRegisteredResourceReviewNamespaces { + return nil, fmt.Errorf("%w: registered resource review requires multiple candidate namespaces", ErrUndeterminedTargetMapping) + } + + return candidates, nil +} + +func registeredResourceConflictPrompt(resource *policy.RegisteredResource, namespaces []*policy.Namespace) SelectPrompt { + description := []string{ + fmt.Sprintf("Registered resource: %s (%s)", strings.TrimSpace(resource.GetName()), resource.GetId()), + "Choose one target namespace for this registered resource.", + "Bindings for other namespaces will be removed from the reviewed RR.", + } + description = append(description, registeredResourceConflictLines(resource)...) + + options := make([]PromptOption, 0, len(namespaces)+1) + for _, namespace := range namespaces { + options = append(options, PromptOption{ + Label: namespaceLabel(namespace), + Value: namespaceSelectionValue(namespace), + Description: "migrate to this namespace", + }) + } + options = append(options, PromptOption{ + Label: "Abort run", + Value: interactiveReviewAbortOption, + Description: "stop planning without changing this RR", + }) + + return SelectPrompt{ + Title: fmt.Sprintf("Registered resource (name: %s, id: %s) spans multiple target namespaces.", resource.GetName(), resource.GetId()), + Description: description, + Options: options, + } +} + +func registeredResourceConflictLines(resource *policy.RegisteredResource) []string { + lines := make([]string, 0) + for _, value := range resource.GetValues() { + if value == nil { + continue + } + if len(value.GetActionAttributeValues()) == 0 { + lines = append(lines, fmt.Sprintf("Value %q has no action bindings.", value.GetValue())) + continue + } + for _, aav := range value.GetActionAttributeValues() { + if aav == nil { + continue + } + lines = append(lines, fmt.Sprintf( + "Value %q: action %q -> %s", + value.GetValue(), + actionLabel(aav.GetAction()), + namespaceLabel(namespaceFromAttributeValue(aav.GetAttributeValue())), + )) + } + } + + return lines +} + +func actionLabel(action *policy.Action) string { + if action == nil { + return unknownLabel + } + if name := strings.TrimSpace(action.GetName()); name != "" { + return name + } + if id := strings.TrimSpace(action.GetId()); id != "" { + return id + } + return unknownLabel +} + +func namespaceSelectionValue(namespace *policy.Namespace) string { + return namespaceRefKey(namespace) +} + +func selectedNamespace(candidates []*policy.Namespace, value string) *policy.Namespace { + for _, namespace := range candidates { + if namespaceSelectionValue(namespace) == value { + return namespace + } + } + + return nil +} + +func filterRegisteredResourceToNamespace(resource *policy.RegisteredResource, namespace *policy.Namespace) (*policy.RegisteredResource, error) { + if resource == nil { + return nil, fmt.Errorf("%w: registered resource is empty", ErrUndeterminedTargetMapping) + } + if namespace == nil { + return nil, fmt.Errorf("%w: empty namespace reference", ErrUndeterminedTargetMapping) + } + + cloned, ok := proto.Clone(resource).(*policy.RegisteredResource) + if !ok { + return nil, errors.New("could not clone registered resource") + } + + clonedValues := cloned.GetValues() + cloned.Values = make([]*policy.RegisteredResourceValue, 0, len(clonedValues)) + for _, value := range clonedValues { + if value == nil { + continue + } + + if len(value.GetActionAttributeValues()) == 0 { + cloned.Values = append(cloned.Values, value) + continue + } + + filteredAAVs := make([]*policy.RegisteredResourceValue_ActionAttributeValue, 0, len(value.GetActionAttributeValues())) + for _, aav := range value.GetActionAttributeValues() { + if aav == nil || !sameNamespace(namespaceFromAttributeValue(aav.GetAttributeValue()), namespace) { + continue + } + filteredAAVs = append(filteredAAVs, aav) + } + + if len(filteredAAVs) == 0 { + continue + } + + value.ActionAttributeValues = filteredAAVs + cloned.Values = append(cloned.Values, value) + } + + return cloned, nil +} + +// ensureRegisteredResourceActionResolution may append or update entries in +// resolved.Actions so the reviewed registered resource's action bindings remain +// executable after namespace-specific filtering. +func ensureRegisteredResourceActionResolution(resolved *ResolvedTargets, resourceID string, namespace *policy.Namespace, action *policy.Action, actionResolver *resolver) error { + if resolved == nil { + return ErrNilResolvedTargets + } + if namespace == nil { + return fmt.Errorf("%w: empty namespace reference", ErrUndeterminedTargetMapping) + } + if action == nil || strings.TrimSpace(action.GetId()) == "" { + return errors.New("registered resource binding action is missing") + } + if actionResolver == nil { + return errors.New("action resolver required for plan resolution") + } + + item := resolvedActionByID(resolved.Actions, action.GetId()) + if item == nil { + source, err := cloneAction(action) + if err != nil { + return err + } + + item = &ResolvedAction{ + Source: source, + Results: make([]*ResolvedActionResult, 0, 1), + } + resolved.Actions = append(resolved.Actions, item) + } else if item.Source == nil { + source, err := cloneAction(action) + if err != nil { + return err + } + + item.Source = source + } + + addActionReferenceIfMissing(item, &ActionReference{ + Kind: ActionReferenceKindRegisteredResource, + ID: resourceID, + Namespace: namespace, + }) + + if resolvedActionResultForNamespace(item, namespace) != nil { + return nil + } + + result, err := actionResolver.resolveActionTargetFromExisting(item.Source, namespace) + if err != nil { + return fmt.Errorf("action %q in namespace %q: %w", item.Source.GetId(), namespace.GetId(), err) + } + + item.Results = append(item.Results, result) + return nil +} + +func resolvedActionByID(actions []*ResolvedAction, sourceID string) *ResolvedAction { + for _, action := range actions { + if action != nil && action.Source != nil && action.Source.GetId() == sourceID { + return action + } + } + + return nil +} + +func resolvedActionResultForNamespace(action *ResolvedAction, namespace *policy.Namespace) *ResolvedActionResult { + if action == nil || namespace == nil { + return nil + } + + for _, result := range action.Results { + if result != nil && sameNamespace(result.Namespace, namespace) { + return result + } + } + + return nil +} + +func addActionReferenceIfMissing(action *ResolvedAction, reference *ActionReference) { + if action == nil || reference == nil { + return + } + + for _, existing := range action.References { + if actionReferenceKey(existing) == actionReferenceKey(reference) { + return + } + } + + action.References = append(action.References, reference) +} + +func cloneAction(action *policy.Action) (*policy.Action, error) { + if action == nil { + return nil, errors.New("action is nil") + } + + cloned, ok := proto.Clone(action).(*policy.Action) + if !ok { + return nil, fmt.Errorf("clone action %q: unexpected proto clone type", action.GetId()) + } + + return cloned, nil +} diff --git a/otdfctl/migrations/namespacedpolicy/interactive_review_test.go b/otdfctl/migrations/namespacedpolicy/interactive_review_test.go new file mode 100644 index 0000000000..ac5e0804f0 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/interactive_review_test.go @@ -0,0 +1,534 @@ +package namespacedpolicy + +import ( + "context" + "testing" + + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/actions" + "github.com/opentdf/platform/protocol/go/policy/namespaces" + "github.com/opentdf/platform/protocol/go/policy/registeredresources" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHuhInteractiveReviewerResolvesConflictingRegisteredResource(t *testing.T) { + t.Parallel() + + leftNamespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + rightNamespace := &policy.Namespace{ + Id: "ns-2", + Fqn: "https://example.org", + } + + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + leftNamespace.GetId(): { + Pagination: emptyPageResponse(), + }, + }, + registeredResourcesByNamespace: map[string]*registeredresources.ListRegisteredResourcesResponse{ + leftNamespace.GetId(): { + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{leftNamespace, rightNamespace}, + Pagination: emptyPageResponse(), + }, + } + prompter := &testInteractivePrompter{ + selectValue: namespaceSelectionValue(leftNamespace), + } + reviewer := NewHuhInteractiveReviewer(handler, prompter) + resolved := &ResolvedTargets{ + Scopes: []Scope{ScopeRegisteredResources}, + RegisteredResources: []*ResolvedRegisteredResource{ + { + Source: testRegisteredResource( + "resource-1", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + "action-1", + "decrypt", + testAttributeValue("https://example.com/attr/classification/value/secret", leftNamespace), + ), + testActionAttributeValue( + "action-2", + "encrypt", + testAttributeValue("https://example.org/attr/classification/value/restricted", rightNamespace), + ), + ), + &policy.RegisteredResourceValue{ + Value: "shared", + }, + ), + Unresolved: &Unresolved{ + Reason: UnresolvedReasonRegisteredResourceConflictingNamespaces, + Message: "could not determine target namespace: registered resource spans multiple target namespaces", + }, + }, + }, + } + + err := reviewer.Review(t.Context(), resolved, []*policy.Namespace{leftNamespace, rightNamespace}) + require.NoError(t, err) + + require.Equal(t, 1, prompter.selectCalls) + require.NotNil(t, prompter.lastSelectPrompt) + assert.Equal(t, "Registered resource (name: documents, id: resource-1) spans multiple target namespaces.", prompter.lastSelectPrompt.Title) + require.Len(t, prompter.lastSelectPrompt.Options, 3) + + require.Len(t, resolved.RegisteredResources, 1) + resource := resolved.RegisteredResources[0] + require.NotNil(t, resource) + assert.Nil(t, resource.Unresolved) + assert.True(t, resource.NeedsCreate) + assert.Nil(t, resource.AlreadyMigrated) + require.True(t, sameNamespace(leftNamespace, resource.Namespace)) + require.Len(t, resource.Source.GetValues(), 2) + require.Len(t, resource.Source.GetValues()[0].GetActionAttributeValues(), 1) + assert.Equal(t, "action-1", resource.Source.GetValues()[0].GetActionAttributeValues()[0].GetAction().GetId()) + assert.Empty(t, resource.Source.GetValues()[1].GetActionAttributeValues()) + + require.Len(t, resolved.Actions, 1) + action := resolved.Actions[0] + require.NotNil(t, action.Source) + assert.Equal(t, "action-1", action.Source.GetId()) + require.Len(t, action.Results, 1) + assert.True(t, sameNamespace(leftNamespace, action.Results[0].Namespace)) + assert.True(t, action.Results[0].NeedsCreate) + require.Len(t, action.References, 1) + assert.Equal(t, ActionReferenceKindRegisteredResource, action.References[0].Kind) + assert.Equal(t, "resource-1", action.References[0].ID) + assert.True(t, sameNamespace(leftNamespace, action.References[0].Namespace)) +} + +func TestHuhInteractiveReviewerSkipsActionResolutionWhenFilteredResourceAlreadyExists(t *testing.T) { + t.Parallel() + + leftNamespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + rightNamespace := &policy.Namespace{ + Id: "ns-2", + Fqn: "https://example.org", + } + + filteredExisting := testRegisteredResource( + "resource-existing", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + "action-existing", + "decrypt", + testAttributeValue("https://example.com/attr/classification/value/secret", leftNamespace), + ), + ), + &policy.RegisteredResourceValue{Value: "shared"}, + ) + + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + leftNamespace.GetId(): { + Pagination: emptyPageResponse(), + }, + }, + registeredResourcesByNamespace: map[string]*registeredresources.ListRegisteredResourcesResponse{ + leftNamespace.GetId(): { + Resources: []*policy.RegisteredResource{filteredExisting}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{leftNamespace, rightNamespace}, + Pagination: emptyPageResponse(), + }, + } + reviewer := NewHuhInteractiveReviewer(handler, &testInteractivePrompter{ + selectValue: namespaceSelectionValue(leftNamespace), + }) + resolved := &ResolvedTargets{ + Scopes: []Scope{ScopeRegisteredResources}, + RegisteredResources: []*ResolvedRegisteredResource{ + { + Source: testRegisteredResource( + "resource-1", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + "action-1", + "decrypt", + testAttributeValue("https://example.com/attr/classification/value/secret", leftNamespace), + ), + testActionAttributeValue( + "action-2", + "encrypt", + testAttributeValue("https://example.org/attr/classification/value/restricted", rightNamespace), + ), + ), + &policy.RegisteredResourceValue{Value: "shared"}, + ), + Unresolved: &Unresolved{ + Reason: UnresolvedReasonRegisteredResourceConflictingNamespaces, + }, + }, + }, + } + + err := reviewer.Review(t.Context(), resolved, []*policy.Namespace{leftNamespace, rightNamespace}) + require.NoError(t, err) + + resource := resolved.RegisteredResources[0] + require.NotNil(t, resource) + assert.Nil(t, resource.Unresolved) + assert.False(t, resource.NeedsCreate) + require.NotNil(t, resource.AlreadyMigrated) + assert.Equal(t, filteredExisting.GetId(), resource.AlreadyMigrated.GetId()) + assert.Empty(t, resolved.Actions) +} + +func TestEnsureRegisteredResourceActionResolutionReusesExistingNamespaceResult(t *testing.T) { + t.Parallel() + + namespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + resolved := &ResolvedTargets{ + Actions: []*ResolvedAction{ + { + Source: &policy.Action{ + Id: "action-1", + Name: "decrypt", + }, + Results: []*ResolvedActionResult{ + { + Namespace: namespace, + NeedsCreate: true, + }, + }, + }, + }, + } + + err := ensureRegisteredResourceActionResolution( + resolved, + "resource-1", + namespace, + &policy.Action{Id: "action-1", Name: "decrypt"}, + &resolver{ + existing: &ExistingTargets{}, + }, + ) + require.NoError(t, err) + + require.Len(t, resolved.Actions, 1) + require.Len(t, resolved.Actions[0].Results, 1) + require.Len(t, resolved.Actions[0].References, 1) + assert.Equal(t, ActionReferenceKindRegisteredResource, resolved.Actions[0].References[0].Kind) + assert.Equal(t, "resource-1", resolved.Actions[0].References[0].ID) + assert.True(t, sameNamespace(namespace, resolved.Actions[0].References[0].Namespace)) +} + +func TestFilterRegisteredResourceToNamespaceRetainsUnboundValues(t *testing.T) { + t.Parallel() + + leftNamespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + rightNamespace := &policy.Namespace{ + Id: "ns-2", + Fqn: "https://example.org", + } + + filtered, err := filterRegisteredResourceToNamespace( + testRegisteredResource( + "resource-1", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + "action-1", + "decrypt", + testAttributeValue("https://example.com/attr/classification/value/secret", leftNamespace), + ), + testActionAttributeValue( + "action-2", + "encrypt", + testAttributeValue("https://example.org/attr/classification/value/restricted", rightNamespace), + ), + ), + &policy.RegisteredResourceValue{Value: "shared"}, + ), + leftNamespace, + ) + require.NoError(t, err) + + require.Len(t, filtered.GetValues(), 2) + require.Len(t, filtered.GetValues()[0].GetActionAttributeValues(), 1) + assert.Equal(t, "action-1", filtered.GetValues()[0].GetActionAttributeValues()[0].GetAction().GetId()) + assert.Empty(t, filtered.GetValues()[1].GetActionAttributeValues()) +} + +func TestRegisteredResourceCandidateNamespacesDeduplicatesNamespaces(t *testing.T) { + t.Parallel() + + leftNamespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + rightNamespace := &policy.Namespace{ + Id: "ns-2", + Fqn: "https://example.org", + } + + candidates, err := registeredResourceCandidateNamespaces( + testRegisteredResource( + "resource-1", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + "action-1", + "decrypt", + testAttributeValue("https://example.com/attr/classification/value/secret", leftNamespace), + ), + testActionAttributeValue( + "action-2", + "decrypt-again", + testAttributeValue("https://example.com/attr/classification/value/internal", leftNamespace), + ), + testActionAttributeValue( + "action-3", + "encrypt", + testAttributeValue("https://example.org/attr/classification/value/restricted", rightNamespace), + ), + ), + ), + []*policy.Namespace{leftNamespace, rightNamespace}, + ) + require.NoError(t, err) + + require.Len(t, candidates, 2) + assert.True(t, sameNamespace(leftNamespace, candidates[0])) + assert.True(t, sameNamespace(rightNamespace, candidates[1])) +} + +func TestHuhInteractiveReviewerReturnsAbortWhenPromptAborts(t *testing.T) { + t.Parallel() + + leftNamespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + rightNamespace := &policy.Namespace{ + Id: "ns-2", + Fqn: "https://example.org", + } + reviewer := NewHuhInteractiveReviewer( + &plannerTestHandler{}, + &testInteractivePrompter{selectErr: ErrInteractiveReviewAborted}, + ) + + err := reviewer.Review(t.Context(), &ResolvedTargets{ + RegisteredResources: []*ResolvedRegisteredResource{ + { + Source: testRegisteredResource( + "resource-1", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + "action-1", + "decrypt", + testAttributeValue("https://example.com/attr/classification/value/secret", leftNamespace), + ), + testActionAttributeValue( + "action-2", + "encrypt", + testAttributeValue("https://example.org/attr/classification/value/restricted", rightNamespace), + ), + ), + ), + Unresolved: &Unresolved{ + Reason: UnresolvedReasonRegisteredResourceConflictingNamespaces, + }, + }, + }, + }, []*policy.Namespace{leftNamespace, rightNamespace}) + require.ErrorIs(t, err, ErrInteractiveReviewAborted) +} + +func TestHuhInteractiveReviewerReturnsNilForNilResolvedTargets(t *testing.T) { + t.Parallel() + + reviewer := NewHuhInteractiveReviewer(&plannerTestHandler{}, &testInteractivePrompter{}) + + err := reviewer.Review(t.Context(), nil, nil) + require.NoError(t, err) +} + +func TestHuhInteractiveReviewerRequiresHandlerForConflictingReview(t *testing.T) { + t.Parallel() + + leftNamespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + rightNamespace := &policy.Namespace{ + Id: "ns-2", + Fqn: "https://example.org", + } + reviewer := NewHuhInteractiveReviewer(nil, &testInteractivePrompter{ + selectValue: namespaceSelectionValue(leftNamespace), + }) + + err := reviewer.Review(t.Context(), &ResolvedTargets{ + RegisteredResources: []*ResolvedRegisteredResource{ + { + Source: testRegisteredResource( + "resource-1", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + "action-1", + "decrypt", + testAttributeValue("https://example.com/attr/classification/value/secret", leftNamespace), + ), + testActionAttributeValue( + "action-2", + "encrypt", + testAttributeValue("https://example.org/attr/classification/value/restricted", rightNamespace), + ), + ), + ), + Unresolved: &Unresolved{ + Reason: UnresolvedReasonRegisteredResourceConflictingNamespaces, + }, + }, + }, + }, []*policy.Namespace{leftNamespace, rightNamespace}) + require.ErrorIs(t, err, ErrNilInteractiveReviewHandler) +} + +func TestHuhInteractiveReviewerCachesNamespaceLookupsWithinReview(t *testing.T) { + t.Parallel() + + leftNamespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + rightNamespace := &policy.Namespace{ + Id: "ns-2", + Fqn: "https://example.org", + } + + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + leftNamespace.GetId(): { + Pagination: emptyPageResponse(), + }, + }, + registeredResourcesByNamespace: map[string]*registeredresources.ListRegisteredResourcesResponse{ + leftNamespace.GetId(): { + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{leftNamespace, rightNamespace}, + Pagination: emptyPageResponse(), + }, + } + prompter := &testInteractivePrompter{ + selectValue: namespaceSelectionValue(leftNamespace), + } + reviewer := NewHuhInteractiveReviewer(handler, prompter) + resolved := &ResolvedTargets{ + Scopes: []Scope{ScopeRegisteredResources}, + RegisteredResources: []*ResolvedRegisteredResource{ + { + Source: testRegisteredResource( + "resource-1", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + "action-1", + "decrypt", + testAttributeValue("https://example.com/attr/classification/value/secret", leftNamespace), + ), + testActionAttributeValue( + "action-2", + "encrypt", + testAttributeValue("https://example.org/attr/classification/value/restricted", rightNamespace), + ), + ), + ), + Unresolved: &Unresolved{ + Reason: UnresolvedReasonRegisteredResourceConflictingNamespaces, + }, + }, + { + Source: testRegisteredResource( + "resource-2", + "records", + testRegisteredResourceValue( + "stage", + testActionAttributeValue( + "action-3", + "review_records", + testAttributeValue("https://example.com/attr/classification/value/internal", leftNamespace), + ), + testActionAttributeValue( + "action-4", + "publish_records", + testAttributeValue("https://example.org/attr/classification/value/restricted", rightNamespace), + ), + ), + ), + Unresolved: &Unresolved{ + Reason: UnresolvedReasonRegisteredResourceConflictingNamespaces, + }, + }, + }, + } + + err := reviewer.Review(t.Context(), resolved, []*policy.Namespace{leftNamespace, rightNamespace}) + require.NoError(t, err) + assert.Equal(t, 2, prompter.selectCalls) + assert.Equal(t, []string{leftNamespace.GetId()}, handler.actionCalls) + assert.Equal(t, []string{leftNamespace.GetId()}, handler.registeredResourceCalls) +} + +type testInteractivePrompter struct { + confirmCalls int + lastConfirmPrompt *ConfirmPrompt + confirmErr error + selectCalls int + lastSelectPrompt *SelectPrompt + selectValue string + selectErr error +} + +func (p *testInteractivePrompter) Confirm(_ context.Context, prompt ConfirmPrompt) error { + p.confirmCalls++ + p.lastConfirmPrompt = &prompt + return p.confirmErr +} + +func (p *testInteractivePrompter) Select(_ context.Context, prompt SelectPrompt) (string, error) { + p.selectCalls++ + p.lastSelectPrompt = &prompt + return p.selectValue, p.selectErr +} diff --git a/otdfctl/migrations/namespacedpolicy/obligation_triggers_execute.go b/otdfctl/migrations/namespacedpolicy/obligation_triggers_execute.go new file mode 100644 index 0000000000..a9e19b527e --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/obligation_triggers_execute.go @@ -0,0 +1,139 @@ +package namespacedpolicy + +import ( + "context" + "fmt" + "strings" + + "github.com/opentdf/platform/protocol/go/policy" +) + +func (e *Executor) executeObligationTriggers(ctx context.Context, plans []*ObligationTriggerPlan) error { + if len(plans) == 0 { + return nil + } + + for _, triggerPlan := range plans { + if triggerPlan == nil || triggerPlan.Source == nil { + continue + } + + for _, target := range triggerPlan.Targets { + if target == nil { + continue + } + + if err := e.executeObligationTriggerTarget(ctx, triggerPlan, target); err != nil { + return err + } + } + } + + return nil +} + +func (e *Executor) executeObligationTriggerTarget(ctx context.Context, triggerPlan *ObligationTriggerPlan, target *ObligationTriggerTargetPlan) error { + //nolint:exhaustive // Obligation-trigger execution only handles create and already-migrated explicitly; all other statuses are unsupported. + switch target.Status { + case TargetStatusAlreadyMigrated: + if target.TargetID() == "" { + return fmt.Errorf("%w: obligation trigger %q target %q", ErrMissingMigratedTarget, triggerPlan.Source.GetId(), namespaceLabel(target.Namespace)) + } + return nil + case TargetStatusCreate: + return e.createObligationTriggerTarget(ctx, triggerPlan, target) + case TargetStatusUnresolved: + return fmt.Errorf("%w: obligation trigger %q target %q is unresolved: %s", ErrPlanNotExecutable, triggerPlan.Source.GetId(), namespaceLabel(target.Namespace), target.Reason) + default: + return fmt.Errorf("%w: obligation trigger %q target %q has unsupported status %q", ErrUnsupportedStatus, triggerPlan.Source.GetId(), namespaceLabel(target.Namespace), target.Status) + } +} + +func (e *Executor) createObligationTriggerTarget(ctx context.Context, triggerPlan *ObligationTriggerPlan, target *ObligationTriggerTargetPlan) error { + actionID, err := e.requireActionTargetID(target.Action, target.Namespace, triggerPlan.Source.GetId()) + if err != nil { + return err + } + + created, err := e.handler.CreateObligationTrigger( + ctx, + valueIDOrFQN(triggerPlan.Source.GetAttributeValue()), + actionID, + obligationValueIDOrFQN(triggerPlan.Source.GetObligationValue()), + triggerClientID(triggerPlan.Source.GetContext()), + metadataForCreate( + triggerPlan.Source.GetId(), + metadataLabels(triggerPlan.Source.GetMetadata()), + e.runID, + ), + ) + if err != nil { + target.Execution = &ExecutionResult{ + RunID: e.runID, + Failure: err.Error(), + } + return fmt.Errorf("create obligation trigger %q in namespace %q: %w", triggerPlan.Source.GetId(), namespaceLabel(target.Namespace), err) + } + if created.GetId() == "" { + target.Execution = &ExecutionResult{ + RunID: e.runID, + Failure: ErrMissingCreatedTargetID.Error(), + } + return fmt.Errorf("%w: obligation trigger %q target %q", ErrMissingCreatedTargetID, triggerPlan.Source.GetId(), namespaceLabel(target.Namespace)) + } + + target.Execution = &ExecutionResult{ + RunID: e.runID, + Applied: true, + CreatedTargetID: created.GetId(), + } + + return nil +} + +// TODO: Eventually make this generic when we merge sm / rr +func (e *Executor) requireActionTargetID(binding *ActionBinding, targetNamespace *policy.Namespace, ownerID string) (string, error) { + if binding == nil { + return "", fmt.Errorf("%w: obligation trigger %q action binding is missing", ErrMissingMigratedTarget, ownerID) + } + + actionID := e.cachedActionTargetID(binding.SourceID, targetNamespace) + if actionID != "" { + return actionID, nil + } + + return "", fmt.Errorf("%w: obligation trigger %q action %q target %q", ErrMissingMigratedTarget, ownerID, binding.SourceID, namespaceLabel(targetNamespace)) +} + +func valueIDOrFQN(value *policy.Value) string { + if value == nil { + return "" + } + if id := strings.TrimSpace(value.GetId()); id != "" { + return id + } + return strings.TrimSpace(value.GetFqn()) +} + +func obligationValueIDOrFQN(value *policy.ObligationValue) string { + if value == nil { + return "" + } + if id := strings.TrimSpace(value.GetId()); id != "" { + return id + } + return strings.TrimSpace(value.GetFqn()) +} + +func triggerClientID(contexts []*policy.RequestContext) string { + for _, requestContext := range contexts { + if requestContext == nil || requestContext.GetPep() == nil { + continue + } + if clientID := strings.TrimSpace(requestContext.GetPep().GetClientId()); clientID != "" { + return clientID + } + } + + return "" +} diff --git a/otdfctl/migrations/namespacedpolicy/obligation_triggers_execute_test.go b/otdfctl/migrations/namespacedpolicy/obligation_triggers_execute_test.go new file mode 100644 index 0000000000..b7f47848c4 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/obligation_triggers_execute_test.go @@ -0,0 +1,419 @@ +package namespacedpolicy + +import ( + "errors" + "testing" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecuteObligationTriggers(t *testing.T) { + t.Parallel() + + namespace1 := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + namespace2 := &policy.Namespace{Id: "ns-2", Fqn: "https://example.net"} + errBoom := errors.New("boom") + + tests := []struct { + name string + plan *Plan + handler *mockExecutorHandler + runID string + wantErr *expectedError + assert func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, plan *Plan) + }{ + { + name: "handles created and already migrated obligation trigger targets", + plan: &Plan{ + Scopes: []Scope{ScopeActions, ScopeObligationTriggers}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{Id: "action-1", Name: "decrypt"}, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + }, + { + Namespace: namespace2, + Status: TargetStatusExistingStandard, + Existing: &policy.Action{Id: "existing-standard-action"}, + }, + }, + }, + }, + ObligationTriggers: []*ObligationTriggerPlan{ + { + Source: &policy.ObligationTrigger{ + Id: "trigger-1", + Action: &policy.Action{Id: "action-1"}, + AttributeValue: &policy.Value{ + Id: "attribute-value-1", + Fqn: "https://example.com/attr/department/value/eng", + }, + ObligationValue: &policy.ObligationValue{ + Id: "obligation-value-1", + Fqn: "https://example.com/obligation/log/value/default", + }, + Context: []*policy.RequestContext{ + {Pep: &policy.PolicyEnforcementPoint{ClientId: "client-a"}}, + }, + Metadata: &common.Metadata{ + Labels: map[string]string{ + "owner": "policy-team", + "env": "dev", + }, + }, + }, + Targets: []*ObligationTriggerTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + Action: &ActionBinding{ + SourceID: "action-1", + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + { + Source: &policy.ObligationTrigger{ + Id: "trigger-2", + Action: &policy.Action{Id: "action-1"}, + }, + Targets: []*ObligationTriggerTargetPlan{ + { + Namespace: namespace2, + Status: TargetStatusAlreadyMigrated, + Existing: &policy.ObligationTrigger{Id: "migrated-trigger-2"}, + Action: &ActionBinding{ + SourceID: "action-1", + Namespace: namespace2, + Status: TargetStatusExistingStandard, + TargetID: "existing-standard-action", + }, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{ + results: map[string]map[string]*policy.Action{ + "decrypt": { + "ns-1": {Id: "created-action-1", Name: "decrypt"}, + }, + }, + obligationTriggerResult: map[string]map[string]*policy.ObligationTrigger{ + "trigger-1": { + "created-action-1": {Id: "created-trigger-1"}, + }, + }, + }, + runID: "run-789", + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.NoError(t, err) + require.Contains(t, handler.createdObligationTriggers, "trigger-1") + require.Contains(t, handler.createdObligationTriggers["trigger-1"], "created-action-1") + + createdCall := handler.createdObligationTriggers["trigger-1"]["created-action-1"] + assert.Equal(t, "attribute-value-1", createdCall.AttributeValue) + assert.Equal(t, "created-action-1", createdCall.Action) + assert.Equal(t, "obligation-value-1", createdCall.ObligationValue) + assert.Equal(t, "client-a", createdCall.ClientID) + assert.Equal(t, map[string]string{ + "owner": "policy-team", + "env": "dev", + migrationLabelMigratedFrom: "trigger-1", + migrationLabelRun: "run-789", + }, createdCall.Metadata.GetLabels()) + + createdTarget := plan.ObligationTriggers[0].Targets[0] + require.NotNil(t, createdTarget.Execution) + assert.True(t, createdTarget.Execution.Applied) + assert.Equal(t, "created-trigger-1", createdTarget.Execution.CreatedTargetID) + assert.Equal(t, "run-789", createdTarget.Execution.RunID) + assert.Equal(t, "created-trigger-1", createdTarget.TargetID()) + + migratedTarget := plan.ObligationTriggers[1].Targets[0] + assert.Equal(t, "migrated-trigger-2", migratedTarget.TargetID()) + assert.Nil(t, migratedTarget.Execution) + }, + }, + { + name: "returns not executable for unresolved target status", + plan: &Plan{ + Scopes: []Scope{ScopeObligationTriggers}, + ObligationTriggers: []*ObligationTriggerPlan{ + { + Source: &policy.ObligationTrigger{Id: "trigger-1"}, + Targets: []*ObligationTriggerTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusUnresolved, + Reason: "missing target namespace mapping", + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError( + ErrPlanNotExecutable, + `obligation trigger %q target %q is unresolved: %s`, + "trigger-1", + namespace1.GetFqn(), + "missing target namespace mapping", + ), + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.createdObligationTriggers) + }, + }, + { + name: "returns error when migrated action target is unavailable", + plan: &Plan{ + Scopes: []Scope{ScopeObligationTriggers}, + ObligationTriggers: []*ObligationTriggerPlan{ + { + Source: &policy.ObligationTrigger{ + Id: "trigger-1", + Action: &policy.Action{Id: "action-1"}, + AttributeValue: &policy.Value{Id: "attribute-value-1"}, + ObligationValue: &policy.ObligationValue{Id: "obligation-value-1"}, + }, + Targets: []*ObligationTriggerTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + Action: &ActionBinding{ + SourceID: "action-1", + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError(ErrMissingMigratedTarget, `obligation trigger %q action %q target %q`, "trigger-1", "action-1", namespace1.GetFqn()), + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.createdObligationTriggers) + assert.Nil(t, plan.ObligationTriggers[0].Targets[0].Execution) + }, + }, + { + name: "returns error for missing already migrated trigger id", + plan: &Plan{ + Scopes: []Scope{ScopeObligationTriggers}, + ObligationTriggers: []*ObligationTriggerPlan{ + { + Source: &policy.ObligationTrigger{Id: "trigger-1"}, + Targets: []*ObligationTriggerTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError(ErrMissingMigratedTarget, `obligation trigger %q target %q`, "trigger-1", namespace1.GetFqn()), + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.createdObligationTriggers) + }, + }, + { + name: "returns error for missing created target id", + plan: &Plan{ + Scopes: []Scope{ScopeActions, ScopeObligationTriggers}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{Id: "action-1", Name: "decrypt"}, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + ObligationTriggers: []*ObligationTriggerPlan{ + { + Source: &policy.ObligationTrigger{ + Id: "trigger-1", + Action: &policy.Action{Id: "action-1"}, + AttributeValue: &policy.Value{Id: "attribute-value-1"}, + ObligationValue: &policy.ObligationValue{Id: "obligation-value-1"}, + }, + Targets: []*ObligationTriggerTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + Action: &ActionBinding{ + SourceID: "action-1", + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{ + results: map[string]map[string]*policy.Action{ + "decrypt": { + "ns-1": {Id: "created-action-1", Name: "decrypt"}, + }, + }, + obligationTriggerResult: map[string]map[string]*policy.ObligationTrigger{ + "trigger-1": { + "created-action-1": {}, + }, + }, + }, + wantErr: wantError(ErrMissingCreatedTargetID, `obligation trigger %q target %q`, "trigger-1", namespace1.GetFqn()), + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.Error(t, err) + require.Contains(t, handler.createdObligationTriggers, "trigger-1") + require.NotNil(t, plan.ObligationTriggers[0].Targets[0].Execution) + assert.Equal(t, ErrMissingCreatedTargetID.Error(), plan.ObligationTriggers[0].Targets[0].Execution.Failure) + }, + }, + { + name: "records create failure from handler", + plan: &Plan{ + Scopes: []Scope{ScopeActions, ScopeObligationTriggers}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{Id: "action-1", Name: "decrypt"}, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + ObligationTriggers: []*ObligationTriggerPlan{ + { + Source: &policy.ObligationTrigger{ + Id: "trigger-1", + Action: &policy.Action{Id: "action-1"}, + AttributeValue: &policy.Value{Id: "attribute-value-1"}, + ObligationValue: &policy.ObligationValue{Id: "obligation-value-1"}, + }, + Targets: []*ObligationTriggerTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + Action: &ActionBinding{ + SourceID: "action-1", + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{ + results: map[string]map[string]*policy.Action{ + "decrypt": { + "ns-1": {Id: "created-action-1", Name: "decrypt"}, + }, + }, + obligationTriggerErrs: map[string]map[string]error{ + "trigger-1": { + "created-action-1": errBoom, + }, + }, + }, + wantErr: &expectedError{ + is: errBoom, + message: `create obligation trigger "trigger-1" in namespace "https://example.com": boom`, + }, + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.Error(t, err) + require.Contains(t, handler.createdObligationTriggers, "trigger-1") + require.NotNil(t, plan.ObligationTriggers[0].Targets[0].Execution) + assert.Equal(t, "boom", plan.ObligationTriggers[0].Targets[0].Execution.Failure) + }, + }, + { + name: "returns error for unsupported target status", + plan: &Plan{ + Scopes: []Scope{ScopeObligationTriggers}, + ObligationTriggers: []*ObligationTriggerPlan{ + { + Source: &policy.ObligationTrigger{Id: "trigger-1"}, + Targets: []*ObligationTriggerTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatus("bogus"), + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError( + ErrUnsupportedStatus, + `obligation trigger %q target %q has unsupported status %q`, + "trigger-1", + namespace1.GetFqn(), + TargetStatus("bogus"), + ), + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.createdObligationTriggers) + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + executor, err := NewExecutor(tt.handler) + require.NoError(t, err) + if tt.runID != "" { + executor.runID = tt.runID + } + + err = executor.Execute(t.Context(), tt.plan) + switch { + case tt.wantErr != nil: + require.Error(t, err) + require.ErrorIs(t, err, tt.wantErr.is) + require.EqualError(t, err, tt.wantErr.message) + default: + require.NoError(t, err) + } + + tt.assert(t, err, executor, tt.handler, tt.plan) + }) + } +} diff --git a/otdfctl/migrations/namespacedpolicy/plan.go b/otdfctl/migrations/namespacedpolicy/plan.go new file mode 100644 index 0000000000..31f47439b2 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/plan.go @@ -0,0 +1,399 @@ +package namespacedpolicy + +import ( + "errors" + "strings" + + identifier "github.com/opentdf/platform/lib/identifier" + "github.com/opentdf/platform/protocol/go/policy" +) + +var ( + ErrNilRetrieved = errors.New("planner retrieved state is required") + ErrMissingTargetNamespace = errors.New("missing target namespace") + ErrUndeterminedTargetMapping = errors.New("could not determine target namespace") + ErrDuplicateCanonicalMatch = errors.New("multiple existing target objects match canonical equality in the target namespace") +) + +type UnresolvedReason string + +const ( + UnresolvedReasonRegisteredResourceConflictingNamespaces UnresolvedReason = "registered_resource_conflicting_namespaces" +) + +type Unresolved struct { + Reason UnresolvedReason + Message string +} + +type Plan struct { + Scopes []Scope `json:"scopes"` + Namespaces []*NamespacePlan `json:"namespaces"` + Actions []*ActionPlan `json:"actions"` + SubjectConditionSets []*SubjectConditionSetPlan `json:"subject_condition_sets"` + SubjectMappings []*SubjectMappingPlan `json:"subject_mappings"` + RegisteredResources []*RegisteredResourcePlan `json:"registered_resources"` + ObligationTriggers []*ObligationTriggerPlan `json:"obligation_triggers"` + Unused *UnusedPlan `json:"unused,omitempty"` + Unresolved *UnresolvedPlan `json:"unresolved,omitempty"` +} + +type NamespacePlan struct { + Namespace *policy.Namespace `json:"namespace"` + Actions []string `json:"actions,omitempty"` + SubjectConditionSets []string `json:"subject_condition_sets,omitempty"` + SubjectMappings []string `json:"subject_mappings,omitempty"` + RegisteredResources []string `json:"registered_resources,omitempty"` + ObligationTriggers []string `json:"obligation_triggers,omitempty"` +} + +type TargetStatus string + +const ( + TargetStatusCreate TargetStatus = "create" + TargetStatusAlreadyMigrated TargetStatus = "already_migrated" + TargetStatusExistingStandard TargetStatus = "existing_standard" + TargetStatusUnresolved TargetStatus = "unresolved" +) + +type ExecutionResult struct { + RunID string `json:"run_id,omitempty"` + Applied bool `json:"applied,omitempty"` + CreatedTargetID string `json:"created_target_id,omitempty"` + Failure string `json:"failure,omitempty"` +} + +type ActionPlan struct { + Source *policy.Action `json:"source"` + // TODO: Add analogous reference metadata for other policy object plan types + // if/when downstream consumers need the same provenance context beyond + // actions. + References []*ActionReference `json:"references,omitempty"` + Targets []*ActionTargetPlan `json:"targets,omitempty"` +} + +type ActionReferenceKind string + +const ( + ActionReferenceKindSubjectMapping ActionReferenceKind = "subject_mapping" + ActionReferenceKindRegisteredResource ActionReferenceKind = "registered_resource" + ActionReferenceKindObligationTrigger ActionReferenceKind = "obligation_trigger" +) + +type ActionReference struct { + Kind ActionReferenceKind `json:"kind"` + ID string `json:"id"` + Namespace *policy.Namespace `json:"namespace,omitempty"` +} + +type ActionTargetPlan struct { + Namespace *policy.Namespace `json:"namespace"` + Status TargetStatus `json:"status"` + Existing *policy.Action `json:"existing,omitempty"` + Execution *ExecutionResult `json:"execution,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type SubjectConditionSetPlan struct { + Source *policy.SubjectConditionSet `json:"source"` + Targets []*SubjectConditionSetTargetPlan `json:"targets,omitempty"` +} + +type SubjectConditionSetTargetPlan struct { + Namespace *policy.Namespace `json:"namespace"` + Status TargetStatus `json:"status"` + Existing *policy.SubjectConditionSet `json:"existing,omitempty"` + Execution *ExecutionResult `json:"execution,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type SubjectMappingPlan struct { + Source *policy.SubjectMapping `json:"source"` + Targets []*SubjectMappingTargetPlan `json:"targets,omitempty"` +} + +type SubjectMappingTargetPlan struct { + Namespace *policy.Namespace `json:"namespace"` + Status TargetStatus `json:"status"` + Existing *policy.SubjectMapping `json:"existing,omitempty"` + Execution *ExecutionResult `json:"execution,omitempty"` + Reason string `json:"reason,omitempty"` + Actions []*ActionBinding `json:"actions,omitempty"` + SubjectConditionSet *SubjectConditionSetBinding `json:"subject_condition_set,omitempty"` +} + +type RegisteredResourcePlan struct { + Source *policy.RegisteredResource `json:"source"` + Targets []*RegisteredResourceTargetPlan `json:"targets,omitempty"` + Unresolved string `json:"unresolved,omitempty"` +} + +type RegisteredResourceTargetPlan struct { + Namespace *policy.Namespace `json:"namespace"` + Status TargetStatus `json:"status"` + // For registered resources, Existing is also used on create targets to mean + // "reuse this parent RR and reconcile missing values under it" rather than + // creating a new top-level RR. + Existing *policy.RegisteredResource `json:"existing,omitempty"` + Execution *ExecutionResult `json:"execution,omitempty"` + Reason string `json:"reason,omitempty"` + Values []*RegisteredResourceValuePlan `json:"values,omitempty"` +} + +type RegisteredResourceValuePlan struct { + Source *policy.RegisteredResourceValue `json:"source"` + ActionBindings []*RegisteredResourceActionBinding `json:"action_bindings,omitempty"` + Execution *ExecutionResult `json:"execution,omitempty"` +} + +type RegisteredResourceActionBinding struct { + SourceActionID string `json:"source_action_id"` + AttributeValue *policy.Value `json:"attribute_value,omitempty"` + ActionTargetRef *ActionBinding `json:"action_target,omitempty"` +} + +type ObligationTriggerPlan struct { + Source *policy.ObligationTrigger `json:"source"` + Targets []*ObligationTriggerTargetPlan `json:"targets,omitempty"` +} + +type ObligationTriggerTargetPlan struct { + Namespace *policy.Namespace `json:"namespace"` + Status TargetStatus `json:"status"` + Existing *policy.ObligationTrigger `json:"existing,omitempty"` + Execution *ExecutionResult `json:"execution,omitempty"` + Reason string `json:"reason,omitempty"` + Action *ActionBinding `json:"action,omitempty"` +} + +// TODO: Revisit this and Scs binding to see what is actually useful +type ActionBinding struct { + SourceID string `json:"source_id"` + Namespace *policy.Namespace `json:"namespace,omitempty"` + Status TargetStatus `json:"status"` + TargetID string `json:"target_id,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type SubjectConditionSetBinding struct { + SourceID string `json:"source_id"` + Namespace *policy.Namespace `json:"namespace,omitempty"` + Status TargetStatus `json:"status"` + TargetID string `json:"target_id,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type UnusedPlan struct { + Actions []*UnusedAction `json:"actions,omitempty"` +} + +type UnusedAction struct { + Source *policy.Action `json:"source"` + References []*ActionReference `json:"references,omitempty"` + Reason string `json:"reason"` +} + +type UnresolvedPlan struct { + RegisteredResources []*RegisteredResourceIssue `json:"registered_resources,omitempty"` +} + +type RegisteredResourceIssue struct { + Resource *policy.RegisteredResource `json:"resource"` + Namespace *policy.Namespace `json:"namespace,omitempty"` + Reason string `json:"reason"` +} + +func namespaceFromAttributeValue(value *policy.Value) *policy.Namespace { + if value == nil { + return nil + } + + if namespace := value.GetAttribute().GetNamespace(); namespaceRefKey(namespace) != "" { + return namespace + } + + parsed, err := identifier.Parse[*identifier.FullyQualifiedAttribute](strings.TrimSpace(value.GetFqn())) + if err != nil || parsed == nil || parsed.Namespace == "" { + return nil + } + + return &policy.Namespace{ + Fqn: (&identifier.FullyQualifiedAttribute{Namespace: parsed.Namespace}).FQN(), + } +} + +func namespaceFromObligationValue(value *policy.ObligationValue) *policy.Namespace { + if value == nil { + return nil + } + return value.GetObligation().GetNamespace() +} + +func hasRegisteredResourceActionAttributeValues(resource *policy.RegisteredResource) bool { + if resource == nil { + return false + } + + for _, value := range resource.GetValues() { + if len(value.GetActionAttributeValues()) > 0 { + return true + } + } + + return false +} + +func hasObject[T interface{ GetId() string }](items []T, id string) bool { + for _, item := range items { + if item.GetId() == id { + return true + } + } + return false +} + +func hasUnresolved(plan UnresolvedPlan) bool { + return len(plan.RegisteredResources) > 0 +} + +func hasUnused(plan UnusedPlan) bool { + return len(plan.Actions) > 0 +} + +// sameNamespace reports whether two namespace references identify the same +// namespace. IDs are compared with whitespace trimmed; FQNs are compared +// case-insensitively with whitespace trimmed. Two nil namespaces are +// considered equal (both represent legacy/global). +// +// NOTE: namespaceRefKey uses raw values without normalization for accumulator +// dedup keys. If normalization bugs surface there, consider unifying with +// this function's normalization logic. +func sameNamespace(left, right *policy.Namespace) bool { + if left == nil || right == nil { + return left == right + } + + leftID := strings.TrimSpace(left.GetId()) + rightID := strings.TrimSpace(right.GetId()) + if leftID != "" && rightID != "" { + return leftID == rightID + } + + leftFQN := strings.ToLower(strings.TrimSpace(left.GetFqn())) + rightFQN := strings.ToLower(strings.TrimSpace(right.GetFqn())) + if leftFQN != "" && rightFQN != "" { + return leftFQN == rightFQN + } + + return false +} + +func (t *ActionTargetPlan) TargetID() string { + if t == nil { + return "" + } + if t.Execution != nil && t.Execution.CreatedTargetID != "" { + return t.Execution.CreatedTargetID + } + if t.Existing == nil { + return "" + } + return t.Existing.GetId() +} + +func (t *SubjectConditionSetTargetPlan) TargetID() string { + if t == nil { + return "" + } + if t.Execution != nil && t.Execution.CreatedTargetID != "" { + return t.Execution.CreatedTargetID + } + if t.Existing == nil { + return "" + } + return t.Existing.GetId() +} + +func (t *SubjectMappingTargetPlan) TargetID() string { + if t == nil { + return "" + } + if t.Execution != nil && t.Execution.CreatedTargetID != "" { + return t.Execution.CreatedTargetID + } + if t.Existing == nil { + return "" + } + return t.Existing.GetId() +} + +func (t *RegisteredResourceTargetPlan) TargetID() string { + if t == nil { + return "" + } + if t.Execution != nil && t.Execution.CreatedTargetID != "" { + return t.Execution.CreatedTargetID + } + if t.Existing == nil { + return "" + } + return t.Existing.GetId() +} + +func (p *RegisteredResourceValuePlan) TargetID() string { + if p == nil || p.Execution == nil { + return "" + } + return p.Execution.CreatedTargetID +} + +func (t *ObligationTriggerTargetPlan) TargetID() string { + if t == nil { + return "" + } + if t.Execution != nil && t.Execution.CreatedTargetID != "" { + return t.Execution.CreatedTargetID + } + if t.Existing == nil { + return "" + } + return t.Existing.GetId() +} + +func (p *Plan) LookupActionTarget(sourceID, namespaceID string) *ActionTargetPlan { + if p == nil || sourceID == "" || namespaceID == "" { + return nil + } + + for _, action := range p.Actions { + if action == nil || action.Source == nil || action.Source.GetId() != sourceID { + continue + } + for _, target := range action.Targets { + if target != nil && target.Namespace != nil && target.Namespace.GetId() == namespaceID { + return target + } + } + } + + return nil +} + +func (p *Plan) LookupSubjectConditionSetTarget(sourceID, namespaceID string) *SubjectConditionSetTargetPlan { + if p == nil || sourceID == "" || namespaceID == "" { + return nil + } + + for _, scs := range p.SubjectConditionSets { + if scs == nil || scs.Source == nil || scs.Source.GetId() != sourceID { + continue + } + for _, target := range scs.Targets { + if target != nil && target.Namespace != nil && target.Namespace.GetId() == namespaceID { + return target + } + } + } + + return nil +} diff --git a/otdfctl/migrations/namespacedpolicy/plan_test.go b/otdfctl/migrations/namespacedpolicy/plan_test.go new file mode 100644 index 0000000000..64be8c5e9a --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/plan_test.go @@ -0,0 +1,19 @@ +package namespacedpolicy + +import ( + "testing" + + "github.com/opentdf/platform/protocol/go/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNamespaceFromAttributeValueFallsBackToValueFQN(t *testing.T) { + t.Parallel() + + namespace := namespaceFromAttributeValue(&policy.Value{ + Fqn: "https://example.com/attr/classification/value/secret", + }) + require.NotNil(t, namespace) + assert.Equal(t, "https://example.com", namespace.GetFqn()) +} diff --git a/otdfctl/migrations/namespacedpolicy/planner.go b/otdfctl/migrations/namespacedpolicy/planner.go new file mode 100644 index 0000000000..15f55c22a5 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/planner.go @@ -0,0 +1,182 @@ +package namespacedpolicy + +import ( + "context" + "errors" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/actions" + "github.com/opentdf/platform/protocol/go/policy/namespaces" + "github.com/opentdf/platform/protocol/go/policy/obligations" + "github.com/opentdf/platform/protocol/go/policy/registeredresources" + "github.com/opentdf/platform/protocol/go/policy/subjectmapping" +) + +const defaultPlannerPageSize int32 = 100 + +var ErrNilPlannerHandler = errors.New("planner handler is required") + +type PolicyClient interface { + ListActions(ctx context.Context, limit, offset int32, namespace string) (*actions.ListActionsResponse, error) + ListSubjectConditionSets(ctx context.Context, limit, offset int32, namespace string) (*subjectmapping.ListSubjectConditionSetsResponse, error) + ListSubjectMappings(ctx context.Context, limit, offset int32, namespace string) (*subjectmapping.ListSubjectMappingsResponse, error) + ListRegisteredResources(ctx context.Context, limit, offset int32, namespace string) (*registeredresources.ListRegisteredResourcesResponse, error) + ListObligationTriggers(ctx context.Context, namespace string, limit, offset int32) (*obligations.ListObligationTriggersResponse, error) + ListNamespaces(ctx context.Context, state common.ActiveStateEnum, limit, offset int32) (*namespaces.ListNamespacesResponse, error) +} + +type Planner struct { + retriever *Retriever + requestedScopes scopeSet + expandedScopes scopeSet + reviewer InteractiveReviewer +} + +type Option func(*Planner) + +type Retrieved struct { + Scopes []Scope + Candidates Candidates +} + +type Candidates struct { + Actions []*policy.Action + SubjectConditionSets []*policy.SubjectConditionSet + SubjectMappings []*policy.SubjectMapping + RegisteredResources []*policy.RegisteredResource + ObligationTriggers []*policy.ObligationTrigger +} + +type ExistingTargets struct { + CustomActions map[string][]*policy.Action + StandardActions map[string][]*policy.Action + SubjectConditionSets map[string][]*policy.SubjectConditionSet + SubjectMappings map[string][]*policy.SubjectMapping + RegisteredResources map[string][]*policy.RegisteredResource + ObligationTriggers map[string][]*policy.ObligationTrigger +} + +func NewPlanner(handler PolicyClient, scopeCSV string, opts ...Option) (*Planner, error) { + if handler == nil { + return nil, ErrNilPlannerHandler + } + + scopes, err := ParseScopes(scopeCSV) + if err != nil { + return nil, err + } + + normalizedScopes, err := normalizeScopes(scopes) + if err != nil { + return nil, err + } + + planner := &Planner{ + retriever: newRetriever(handler, defaultPlannerPageSize), + requestedScopes: normalizedScopes, + expandedScopes: expandScopes(normalizedScopes), + } + for _, opt := range opts { + opt(planner) + } + if planner.retriever.pageSize <= 0 { + planner.retriever.pageSize = defaultPlannerPageSize + } + + return planner, nil +} + +func WithPageSize(pageSize int32) Option { + return func(planner *Planner) { + planner.retriever.pageSize = pageSize + } +} + +func WithInteractiveReviewer(reviewer InteractiveReviewer) Option { + return func(planner *Planner) { + planner.reviewer = reviewer + } +} + +func (p *Planner) Plan(ctx context.Context) (*Plan, error) { + retrieved, err := p.retrieve(ctx) + if err != nil { + return nil, err + } + + namespaces, err := p.retriever.listNamespaces(ctx) + if err != nil { + return nil, err + } + + derived, err := deriveTargets(retrieved, namespaces) + if err != nil { + return nil, err + } + + existingTargets, err := p.retriever.listExistingTargets(ctx, p.requestedScopes, derived) + if err != nil { + return nil, err + } + + resolved, err := resolveExisting(derived, existingTargets) + if err != nil { + return nil, err + } + + if p.reviewer != nil { + if err := p.reviewer.Review(ctx, resolved, namespaces); err != nil { + return nil, err + } + } + + return finalizePlan(resolved, namespaces) +} + +// Retrieve the candidate policy constructs for items within scope or dependent +// on that scope. +func (p *Planner) retrieve(ctx context.Context) (*Retrieved, error) { + if p == nil || p.retriever == nil || p.retriever.handler == nil { + return nil, ErrNilPlannerHandler + } + if len(p.requestedScopes) == 0 { + return nil, ErrEmptyPlannerScope + } + + retrieved, err := p.retriever.retrieve(ctx, p.requestedScopes) + if err != nil { + return nil, err + } + + reduceDependencies(retrieved, p.requestedScopes) + // Keep retrieval/reduction keyed off requestedScopes so "actions" does not + // implicitly pull in reverse-lookup scopes like registered resources or + // obligation triggers. The retrieved artifact still records expandedScopes to + // reflect the full dependency closure used by later planner stages. + retrieved.Scopes = p.expandedScopes.ordered() + + return retrieved, nil +} + +func newRetrieved(scopes []Scope) *Retrieved { + return &Retrieved{ + Scopes: append([]Scope(nil), scopes...), + Candidates: newCandidates(), + } +} + +func newCandidates() Candidates { + return Candidates{} +} + +func newExistingTargets() *ExistingTargets { + return &ExistingTargets{ + CustomActions: make(map[string][]*policy.Action), + StandardActions: make(map[string][]*policy.Action), + SubjectConditionSets: make(map[string][]*policy.SubjectConditionSet), + SubjectMappings: make(map[string][]*policy.SubjectMapping), + RegisteredResources: make(map[string][]*policy.RegisteredResource), + ObligationTriggers: make(map[string][]*policy.ObligationTrigger), + } +} diff --git a/otdfctl/migrations/namespacedpolicy/planner_test.go b/otdfctl/migrations/namespacedpolicy/planner_test.go new file mode 100644 index 0000000000..a4eafa9d7d --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/planner_test.go @@ -0,0 +1,905 @@ +package namespacedpolicy + +import ( + "context" + "errors" + "testing" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/actions" + "github.com/opentdf/platform/protocol/go/policy/namespaces" + "github.com/opentdf/platform/protocol/go/policy/obligations" + "github.com/opentdf/platform/protocol/go/policy/registeredresources" + "github.com/opentdf/platform/protocol/go/policy/subjectmapping" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPlannerPlanMarksActionAlreadyMigratedWithoutMetadata(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + legacyAction := &policy.Action{ + Id: "action-legacy", + Name: "decrypt", + } + targetAction := &policy.Action{ + Id: "action-target", + Name: "decrypt", + Namespace: targetNamespace, + } + legacyMapping := &policy.SubjectMapping{ + Id: "mapping-legacy", + Actions: []*policy.Action{ + { + Id: legacyAction.GetId(), + Name: legacyAction.GetName(), + }, + }, + AttributeValue: &policy.Value{ + Fqn: "https://example.com/attr/classification/value/secret", + }, + } + + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + ActionsCustom: []*policy.Action{targetAction}, + Pagination: emptyPageResponse(), + }, + }, + subjectMappingsByNamespace: map[string]*subjectmapping.ListSubjectMappingsResponse{ + "": { + SubjectMappings: []*policy.SubjectMapping{legacyMapping}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPlanner(handler, "actions") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + require.Len(t, plan.Actions, 1) + require.Len(t, plan.Actions[0].Targets, 1) + + assert.Equal(t, TargetStatusAlreadyMigrated, plan.Actions[0].Targets[0].Status) + require.NotNil(t, plan.Actions[0].Targets[0].Existing) + assert.Equal(t, targetAction.GetId(), plan.Actions[0].Targets[0].Existing.GetId()) + assert.Equal(t, []string{"", targetNamespace.GetId()}, handler.actionCalls) + assert.Equal(t, []string{""}, handler.subjectMappingCalls) +} + +func TestPlannerPlanDoesNotLeakSupportSubjectMappingsIntoActionScope(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + legacyCreate := &policy.Action{ + Id: "action-create", + Name: "create", + } + legacyRead := &policy.Action{ + Id: "action-read", + Name: "read", + } + legacyCustom := &policy.Action{ + Id: "action-custom-1", + Name: "custom_action_1", + } + legacySCS := &policy.SubjectConditionSet{ + Id: "scs-1", + } + resourceValue := &policy.RegisteredResourceValue{ + Id: "resource-value-1", + Resource: &policy.RegisteredResource{Id: "resource-1"}, + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Id: "aav-create", + Action: &policy.Action{ + Id: legacyCreate.GetId(), + Name: legacyCreate.GetName(), + }, + AttributeValue: &policy.Value{ + Attribute: &policy.Attribute{ + Namespace: targetNamespace, + }, + }, + }, + { + Id: "aav-custom", + Action: &policy.Action{ + Id: legacyCustom.GetId(), + Name: legacyCustom.GetName(), + }, + AttributeValue: &policy.Value{ + Attribute: &policy.Attribute{ + Namespace: targetNamespace, + }, + }, + }, + }, + } + legacyResource := &policy.RegisteredResource{ + Id: "resource-1", + Name: "resource-1", + Values: []*policy.RegisteredResourceValue{resourceValue}, + } + legacyMapping := &policy.SubjectMapping{ + Id: "mapping-1", + AttributeValue: &policy.Value{ + Attribute: &policy.Attribute{ + Namespace: targetNamespace, + }, + }, + SubjectConditionSet: &policy.SubjectConditionSet{ + Id: legacySCS.GetId(), + }, + Actions: []*policy.Action{ + { + Id: legacyRead.GetId(), + Name: legacyRead.GetName(), + }, + }, + } + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsStandard: []*policy.Action{legacyCreate, legacyRead}, + ActionsCustom: []*policy.Action{legacyCustom}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + ActionsStandard: []*policy.Action{ + { + Id: "target-create", + Name: legacyCreate.GetName(), + Namespace: targetNamespace, + }, + }, + Pagination: emptyPageResponse(), + }, + }, + subjectConditionSetsByNamespace: map[string]*subjectmapping.ListSubjectConditionSetsResponse{ + "": { + SubjectConditionSets: []*policy.SubjectConditionSet{legacySCS}, + Pagination: emptyPageResponse(), + }, + }, + subjectMappingsByNamespace: map[string]*subjectmapping.ListSubjectMappingsResponse{ + "": { + SubjectMappings: []*policy.SubjectMapping{legacyMapping}, + Pagination: emptyPageResponse(), + }, + }, + registeredResourcesByNamespace: map[string]*registeredresources.ListRegisteredResourcesResponse{ + "": { + Resources: []*policy.RegisteredResource{legacyResource}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPlanner(handler, "subject-condition-sets,registered-resources") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + assert.Equal(t, []Scope{ScopeActions, ScopeSubjectConditionSets, ScopeRegisteredResources}, plan.Scopes) + assert.ElementsMatch(t, []string{legacyCreate.GetId(), legacyCustom.GetId()}, actionSourceIDs(plan.Actions)) + assert.NotContains(t, actionSourceIDs(plan.Actions), legacyRead.GetId()) + require.Len(t, plan.SubjectConditionSets, 1) + assert.Equal(t, legacySCS.GetId(), plan.SubjectConditionSets[0].Source.GetId()) + assert.Equal(t, []string{"", targetNamespace.GetId()}, handler.actionCalls) + assert.Equal(t, []string{""}, handler.subjectMappingCalls) +} + +func TestPlannerRetrieveUsesRequestedScopeBoundaries(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + legacyAction := &policy.Action{ + Id: "action-1", + Name: "decrypt", + } + legacySCS := &policy.SubjectConditionSet{ + Id: "scs-1", + } + legacyMapping := &policy.SubjectMapping{ + Id: "mapping-1", + AttributeValue: testAttributeValue( + "https://example.com/attr/classification/value/secret", + targetNamespace, + ), + SubjectConditionSet: &policy.SubjectConditionSet{ + Id: legacySCS.GetId(), + }, + Actions: []*policy.Action{ + { + Id: legacyAction.GetId(), + Name: legacyAction.GetName(), + }, + }, + } + legacyResource := testRegisteredResource( + "resource-1", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + legacyAction.GetId(), + legacyAction.GetName(), + testAttributeValue("https://example.com/attr/classification/value/secret", targetNamespace), + ), + ), + ) + legacyTrigger := &policy.ObligationTrigger{ + Id: "trigger-1", + Action: &policy.Action{Id: legacyAction.GetId(), Name: legacyAction.GetName()}, + ObligationValue: &policy.ObligationValue{ + Id: "ov-1", + Fqn: "https://example.com/obl/notify/value/email", + Obligation: &policy.Obligation{ + Namespace: targetNamespace, + }, + }, + } + + tests := []struct { + name string + scopeCSV string + expectedScopes []Scope + expectedActionCalls []string + expectedSubjectConditionCalls []string + expectedSubjectMappingCalls []string + expectedRegisteredResourceCall []string + expectedObligationCalls []string + expectedCandidateCounts Candidates + }{ + { + name: "subject mappings pull dependencies without reverse lookup scopes", + scopeCSV: "subject-mappings", + expectedScopes: []Scope{ScopeActions, ScopeSubjectConditionSets, ScopeSubjectMappings}, + expectedActionCalls: []string{ + "", + }, + expectedSubjectConditionCalls: []string{ + "", + }, + expectedSubjectMappingCalls: []string{ + "", + }, + expectedCandidateCounts: Candidates{ + Actions: []*policy.Action{legacyAction}, + SubjectConditionSets: []*policy.SubjectConditionSet{legacySCS}, + SubjectMappings: []*policy.SubjectMapping{legacyMapping}, + }, + }, + { + name: "actions pull reverse lookup scopes without expanding artifact scopes", + scopeCSV: "actions", + expectedScopes: []Scope{ScopeActions}, + expectedActionCalls: []string{ + "", + }, + expectedSubjectMappingCalls: []string{ + "", + }, + expectedRegisteredResourceCall: []string{ + "", + }, + expectedObligationCalls: []string{ + "", + }, + expectedCandidateCounts: Candidates{ + Actions: []*policy.Action{legacyAction}, + SubjectMappings: []*policy.SubjectMapping{legacyMapping}, + RegisteredResources: []*policy.RegisteredResource{legacyResource}, + ObligationTriggers: []*policy.ObligationTrigger{legacyTrigger}, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + }, + subjectConditionSetsByNamespace: map[string]*subjectmapping.ListSubjectConditionSetsResponse{ + "": { + SubjectConditionSets: []*policy.SubjectConditionSet{legacySCS}, + Pagination: emptyPageResponse(), + }, + }, + subjectMappingsByNamespace: map[string]*subjectmapping.ListSubjectMappingsResponse{ + "": { + SubjectMappings: []*policy.SubjectMapping{legacyMapping}, + Pagination: emptyPageResponse(), + }, + }, + registeredResourcesByNamespace: map[string]*registeredresources.ListRegisteredResourcesResponse{ + "": { + Resources: []*policy.RegisteredResource{legacyResource}, + Pagination: emptyPageResponse(), + }, + }, + obligationTriggersByNamespace: map[string]*obligations.ListObligationTriggersResponse{ + "": { + Triggers: []*policy.ObligationTrigger{legacyTrigger}, + Pagination: emptyPageResponse(), + }, + }, + } + + planner, err := NewPlanner(handler, tt.scopeCSV) + require.NoError(t, err) + + retrieved, err := planner.retrieve(t.Context()) + require.NoError(t, err) + + assert.Equal(t, tt.expectedScopes, retrieved.Scopes) + assert.Equal(t, tt.expectedActionCalls, handler.actionCalls) + assert.Equal(t, tt.expectedSubjectConditionCalls, handler.subjectConditionSetCalls) + assert.Equal(t, tt.expectedSubjectMappingCalls, handler.subjectMappingCalls) + assert.Equal(t, tt.expectedRegisteredResourceCall, handler.registeredResourceCalls) + assert.Equal(t, tt.expectedObligationCalls, handler.obligationTriggerCalls) + assert.Len(t, retrieved.Candidates.Actions, len(tt.expectedCandidateCounts.Actions)) + assert.Len(t, retrieved.Candidates.SubjectConditionSets, len(tt.expectedCandidateCounts.SubjectConditionSets)) + assert.Len(t, retrieved.Candidates.SubjectMappings, len(tt.expectedCandidateCounts.SubjectMappings)) + assert.Len(t, retrieved.Candidates.RegisteredResources, len(tt.expectedCandidateCounts.RegisteredResources)) + assert.Len(t, retrieved.Candidates.ObligationTriggers, len(tt.expectedCandidateCounts.ObligationTriggers)) + }) + } +} + +func TestPlannerPlanAllScopesBuildsAllPlanSections(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + legacyAction := &policy.Action{ + Id: "action-1", + Name: "decrypt", + } + legacySCS := &policy.SubjectConditionSet{ + Id: "scs-1", + } + legacyMapping := &policy.SubjectMapping{ + Id: "mapping-1", + AttributeValue: testAttributeValue( + "https://example.com/attr/classification/value/secret", + targetNamespace, + ), + SubjectConditionSet: &policy.SubjectConditionSet{ + Id: legacySCS.GetId(), + }, + Actions: []*policy.Action{ + { + Id: legacyAction.GetId(), + Name: legacyAction.GetName(), + }, + }, + } + legacyResource := testRegisteredResource( + "resource-1", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + legacyAction.GetId(), + legacyAction.GetName(), + testAttributeValue("https://example.com/attr/classification/value/secret", targetNamespace), + ), + ), + ) + legacyTrigger := &policy.ObligationTrigger{ + Id: "trigger-1", + Action: &policy.Action{Id: legacyAction.GetId(), Name: legacyAction.GetName()}, + ObligationValue: &policy.ObligationValue{ + Id: "ov-1", + Fqn: "https://example.com/obl/notify/value/email", + Obligation: &policy.Obligation{ + Namespace: targetNamespace, + }, + }, + } + + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + Pagination: emptyPageResponse(), + }, + }, + subjectConditionSetsByNamespace: map[string]*subjectmapping.ListSubjectConditionSetsResponse{ + "": { + SubjectConditionSets: []*policy.SubjectConditionSet{legacySCS}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + Pagination: emptyPageResponse(), + }, + }, + subjectMappingsByNamespace: map[string]*subjectmapping.ListSubjectMappingsResponse{ + "": { + SubjectMappings: []*policy.SubjectMapping{legacyMapping}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + Pagination: emptyPageResponse(), + }, + }, + registeredResourcesByNamespace: map[string]*registeredresources.ListRegisteredResourcesResponse{ + "": { + Resources: []*policy.RegisteredResource{legacyResource}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + Pagination: emptyPageResponse(), + }, + }, + obligationTriggersByNamespace: map[string]*obligations.ListObligationTriggersResponse{ + "": { + Triggers: []*policy.ObligationTrigger{legacyTrigger}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPlanner(handler, "actions,subject-condition-sets,subject-mappings,registered-resources,obligation-triggers") + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + assert.Equal(t, []Scope{ + ScopeActions, + ScopeSubjectConditionSets, + ScopeSubjectMappings, + ScopeRegisteredResources, + ScopeObligationTriggers, + }, plan.Scopes) + require.Len(t, plan.Actions, 1) + require.Len(t, plan.Actions[0].Targets, 1) + assert.Equal(t, TargetStatusCreate, plan.Actions[0].Targets[0].Status) + assert.ElementsMatch(t, []string{ + "subject_mapping|mapping-1", + "registered_resource|resource-1", + "obligation_trigger|trigger-1", + }, actionReferenceKindsAndIDs(plan.Actions[0].References)) + + require.Len(t, plan.SubjectConditionSets, 1) + require.Len(t, plan.SubjectConditionSets[0].Targets, 1) + assert.Equal(t, TargetStatusCreate, plan.SubjectConditionSets[0].Targets[0].Status) + + require.Len(t, plan.SubjectMappings, 1) + require.Len(t, plan.SubjectMappings[0].Targets, 1) + assert.Equal(t, TargetStatusCreate, plan.SubjectMappings[0].Targets[0].Status) + require.Len(t, plan.SubjectMappings[0].Targets[0].Actions, 1) + assert.Equal(t, TargetStatusCreate, plan.SubjectMappings[0].Targets[0].Actions[0].Status) + require.NotNil(t, plan.SubjectMappings[0].Targets[0].SubjectConditionSet) + assert.Equal(t, TargetStatusCreate, plan.SubjectMappings[0].Targets[0].SubjectConditionSet.Status) + + require.Len(t, plan.RegisteredResources, 1) + require.Len(t, plan.RegisteredResources[0].Targets, 1) + require.Len(t, plan.RegisteredResources[0].Targets[0].Values, 1) + require.Len(t, plan.RegisteredResources[0].Targets[0].Values[0].ActionBindings, 1) + assert.Equal(t, TargetStatusCreate, plan.RegisteredResources[0].Targets[0].Values[0].ActionBindings[0].ActionTargetRef.Status) + + require.Len(t, plan.ObligationTriggers, 1) + require.Len(t, plan.ObligationTriggers[0].Targets, 1) + require.NotNil(t, plan.ObligationTriggers[0].Targets[0].Action) + assert.Equal(t, TargetStatusCreate, plan.ObligationTriggers[0].Targets[0].Action.Status) + + require.Len(t, plan.Namespaces, 1) + assert.Equal(t, []string{legacyAction.GetId()}, plan.Namespaces[0].Actions) + assert.Equal(t, []string{legacySCS.GetId()}, plan.Namespaces[0].SubjectConditionSets) + assert.Equal(t, []string{legacyMapping.GetId()}, plan.Namespaces[0].SubjectMappings) + assert.Equal(t, []string{legacyResource.GetId()}, plan.Namespaces[0].RegisteredResources) + assert.Equal(t, []string{legacyTrigger.GetId()}, plan.Namespaces[0].ObligationTriggers) + + assert.Equal(t, []string{"", targetNamespace.GetId()}, handler.actionCalls) + assert.Equal(t, []string{"", targetNamespace.GetId()}, handler.subjectConditionSetCalls) + assert.Equal(t, []string{"", targetNamespace.GetId()}, handler.subjectMappingCalls) + assert.Equal(t, []string{"", targetNamespace.GetId()}, handler.registeredResourceCalls) + assert.Equal(t, []string{"", targetNamespace.GetId()}, handler.obligationTriggerCalls) +} + +func TestPlannerPlanInvokesInteractiveReviewerWhenConfigured(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + legacyAction := &policy.Action{ + Id: "action-legacy", + Name: "decrypt", + } + legacyMapping := &policy.SubjectMapping{ + Id: "mapping-legacy", + Actions: []*policy.Action{ + { + Id: legacyAction.GetId(), + Name: legacyAction.GetName(), + }, + }, + AttributeValue: &policy.Value{ + Fqn: "https://example.com/attr/classification/value/secret", + }, + } + reviewer := &plannerTestReviewer{} + + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + Pagination: emptyPageResponse(), + }, + }, + subjectMappingsByNamespace: map[string]*subjectmapping.ListSubjectMappingsResponse{ + "": { + SubjectMappings: []*policy.SubjectMapping{legacyMapping}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPlanner(handler, "actions", WithInteractiveReviewer(reviewer)) + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + require.Len(t, plan.Actions, 1) + assert.Equal(t, 1, reviewer.calls) + assert.NotNil(t, reviewer.lastResolved) +} + +func TestPlannerPlanPropagatesInteractiveReviewerError(t *testing.T) { + t.Parallel() + + targetNamespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + legacyAction := &policy.Action{ + Id: "action-legacy", + Name: "decrypt", + } + legacyMapping := &policy.SubjectMapping{ + Id: "mapping-legacy", + Actions: []*policy.Action{ + { + Id: legacyAction.GetId(), + Name: legacyAction.GetName(), + }, + }, + AttributeValue: &policy.Value{ + Fqn: "https://example.com/attr/classification/value/secret", + }, + } + reviewerErr := errors.New("review failed") + reviewer := &plannerTestReviewer{err: reviewerErr} + + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + targetNamespace.GetId(): { + Pagination: emptyPageResponse(), + }, + }, + subjectMappingsByNamespace: map[string]*subjectmapping.ListSubjectMappingsResponse{ + "": { + SubjectMappings: []*policy.SubjectMapping{legacyMapping}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{targetNamespace}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPlanner(handler, "actions", WithInteractiveReviewer(reviewer)) + require.NoError(t, err) + + _, err = planner.Plan(t.Context()) + require.ErrorIs(t, err, reviewerErr) + assert.Equal(t, 1, reviewer.calls) +} + +func TestPlannerPlanInteractiveReviewerLeavesCurrentUnresolvedPlanShapeUntouched(t *testing.T) { + t.Parallel() + + namespaceOne := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + namespaceTwo := &policy.Namespace{ + Id: "ns-2", + Fqn: "https://example.org", + } + legacyAction := &policy.Action{ + Id: "action-legacy", + Name: "decrypt", + } + legacyResource := testRegisteredResource( + "resource-1", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + legacyAction.GetId(), + legacyAction.GetName(), + testAttributeValue("https://example.com/attr/classification/value/secret", namespaceOne), + ), + testActionAttributeValue( + legacyAction.GetId(), + legacyAction.GetName(), + testAttributeValue("https://example.org/attr/classification/value/restricted", namespaceTwo), + ), + ), + ) + reviewer := &plannerTestReviewer{} + + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + }, + registeredResourcesByNamespace: map[string]*registeredresources.ListRegisteredResourcesResponse{ + "": { + Resources: []*policy.RegisteredResource{legacyResource}, + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{namespaceOne, namespaceTwo}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPlanner(handler, "registered-resources", WithInteractiveReviewer(reviewer)) + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + assert.Equal(t, 1, reviewer.calls) + require.Len(t, plan.RegisteredResources, 1) + assert.Equal(t, ErrUndeterminedTargetMapping.Error()+": registered resource spans multiple target namespaces", plan.RegisteredResources[0].Unresolved) + assert.Empty(t, plan.RegisteredResources[0].Targets) + require.NotNil(t, plan.Unresolved) + require.Len(t, plan.Unresolved.RegisteredResources, 1) + assert.Equal(t, legacyResource.GetId(), plan.Unresolved.RegisteredResources[0].Resource.GetId()) + assert.Equal(t, plan.RegisteredResources[0].Unresolved, plan.Unresolved.RegisteredResources[0].Reason) +} + +func TestPlannerPlanHuhInteractiveReviewerResolvesRegisteredResourceConflict(t *testing.T) { + t.Parallel() + + namespaceOne := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + namespaceTwo := &policy.Namespace{ + Id: "ns-2", + Fqn: "https://example.org", + } + legacyAction := &policy.Action{ + Id: "action-legacy", + Name: "decrypt", + } + legacyResource := testRegisteredResource( + "resource-1", + "documents", + testRegisteredResourceValue( + "prod", + testActionAttributeValue( + legacyAction.GetId(), + legacyAction.GetName(), + testAttributeValue("https://example.com/attr/classification/value/secret", namespaceOne), + ), + testActionAttributeValue( + legacyAction.GetId(), + legacyAction.GetName(), + testAttributeValue("https://example.org/attr/classification/value/restricted", namespaceTwo), + ), + ), + ) + prompter := &testInteractivePrompter{ + selectValue: namespaceSelectionValue(namespaceOne), + } + + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsCustom: []*policy.Action{legacyAction}, + Pagination: emptyPageResponse(), + }, + namespaceOne.GetId(): { + Pagination: emptyPageResponse(), + }, + }, + registeredResourcesByNamespace: map[string]*registeredresources.ListRegisteredResourcesResponse{ + "": { + Resources: []*policy.RegisteredResource{legacyResource}, + Pagination: emptyPageResponse(), + }, + namespaceOne.GetId(): { + Pagination: emptyPageResponse(), + }, + }, + namespacesResponse: &namespaces.ListNamespacesResponse{ + Namespaces: []*policy.Namespace{namespaceOne, namespaceTwo}, + Pagination: emptyPageResponse(), + }, + } + + planner, err := NewPlanner(handler, "registered-resources", WithInteractiveReviewer(NewHuhInteractiveReviewer(handler, prompter))) + require.NoError(t, err) + + plan, err := planner.Plan(t.Context()) + require.NoError(t, err) + + assert.Equal(t, 1, prompter.selectCalls) + require.Len(t, plan.RegisteredResources, 1) + assert.Empty(t, plan.RegisteredResources[0].Unresolved) + require.Len(t, plan.RegisteredResources[0].Targets, 1) + assert.Equal(t, TargetStatusCreate, plan.RegisteredResources[0].Targets[0].Status) + assert.True(t, sameNamespace(namespaceOne, plan.RegisteredResources[0].Targets[0].Namespace)) + require.Len(t, plan.RegisteredResources[0].Targets[0].Values, 1) + require.Len(t, plan.RegisteredResources[0].Targets[0].Values[0].ActionBindings, 1) + assert.Equal(t, "action-legacy", plan.RegisteredResources[0].Targets[0].Values[0].ActionBindings[0].SourceActionID) + require.NotNil(t, plan.RegisteredResources[0].Targets[0].Values[0].ActionBindings[0].ActionTargetRef) + assert.Equal(t, TargetStatusCreate, plan.RegisteredResources[0].Targets[0].Values[0].ActionBindings[0].ActionTargetRef.Status) + assert.Nil(t, plan.Unresolved) + require.Len(t, plan.Actions, 1) + require.Len(t, plan.Actions[0].Targets, 1) + assert.Equal(t, TargetStatusCreate, plan.Actions[0].Targets[0].Status) + assert.True(t, sameNamespace(namespaceOne, plan.Actions[0].Targets[0].Namespace)) +} + +type plannerTestHandler struct { + actionsByNamespace map[string]*actions.ListActionsResponse + subjectConditionSetsByNamespace map[string]*subjectmapping.ListSubjectConditionSetsResponse + subjectMappingsByNamespace map[string]*subjectmapping.ListSubjectMappingsResponse + registeredResourcesByNamespace map[string]*registeredresources.ListRegisteredResourcesResponse + obligationTriggersByNamespace map[string]*obligations.ListObligationTriggersResponse + namespacesResponse *namespaces.ListNamespacesResponse + actionCalls []string + subjectConditionSetCalls []string + subjectMappingCalls []string + registeredResourceCalls []string + obligationTriggerCalls []string +} + +func (h *plannerTestHandler) ListActions(_ context.Context, limit, offset int32, namespace string) (*actions.ListActionsResponse, error) { + h.actionCalls = append(h.actionCalls, namespace) + if resp, ok := h.actionsByNamespace[namespace]; ok { + return resp, nil + } + return &actions.ListActionsResponse{Pagination: emptyPageResponse()}, nil +} + +func (h *plannerTestHandler) ListSubjectConditionSets(_ context.Context, limit, offset int32, namespace string) (*subjectmapping.ListSubjectConditionSetsResponse, error) { + h.subjectConditionSetCalls = append(h.subjectConditionSetCalls, namespace) + if resp, ok := h.subjectConditionSetsByNamespace[namespace]; ok { + return resp, nil + } + return &subjectmapping.ListSubjectConditionSetsResponse{Pagination: emptyPageResponse()}, nil +} + +func (h *plannerTestHandler) ListSubjectMappings(_ context.Context, limit, offset int32, namespace string) (*subjectmapping.ListSubjectMappingsResponse, error) { + h.subjectMappingCalls = append(h.subjectMappingCalls, namespace) + if resp, ok := h.subjectMappingsByNamespace[namespace]; ok { + return resp, nil + } + return &subjectmapping.ListSubjectMappingsResponse{Pagination: emptyPageResponse()}, nil +} + +func (h *plannerTestHandler) ListRegisteredResources(_ context.Context, limit, offset int32, namespace string) (*registeredresources.ListRegisteredResourcesResponse, error) { + h.registeredResourceCalls = append(h.registeredResourceCalls, namespace) + if resp, ok := h.registeredResourcesByNamespace[namespace]; ok { + return resp, nil + } + return ®isteredresources.ListRegisteredResourcesResponse{Pagination: emptyPageResponse()}, nil +} + +func (h *plannerTestHandler) ListObligationTriggers(_ context.Context, namespace string, limit, offset int32) (*obligations.ListObligationTriggersResponse, error) { + h.obligationTriggerCalls = append(h.obligationTriggerCalls, namespace) + if resp, ok := h.obligationTriggersByNamespace[namespace]; ok { + return resp, nil + } + return &obligations.ListObligationTriggersResponse{Pagination: emptyPageResponse()}, nil +} + +func (h *plannerTestHandler) ListNamespaces(_ context.Context, state common.ActiveStateEnum, limit, offset int32) (*namespaces.ListNamespacesResponse, error) { + if h.namespacesResponse != nil { + return h.namespacesResponse, nil + } + return &namespaces.ListNamespacesResponse{Pagination: emptyPageResponse()}, nil +} + +func emptyPageResponse() *policy.PageResponse { + return &policy.PageResponse{} +} + +func actionSourceIDs(actions []*ActionPlan) []string { + ids := make([]string, 0, len(actions)) + for _, action := range actions { + if action == nil || action.Source == nil { + continue + } + ids = append(ids, action.Source.GetId()) + } + + return ids +} + +type plannerTestReviewer struct { + calls int + lastResolved *ResolvedTargets + err error +} + +func (r *plannerTestReviewer) Review(_ context.Context, resolved *ResolvedTargets, _ []*policy.Namespace) error { + r.calls++ + r.lastResolved = resolved + return r.err +} diff --git a/otdfctl/migrations/namespacedpolicy/reduce.go b/otdfctl/migrations/namespacedpolicy/reduce.go new file mode 100644 index 0000000000..190af09471 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/reduce.go @@ -0,0 +1,137 @@ +package namespacedpolicy + +import ( + "github.com/opentdf/platform/protocol/go/policy" +) + +// reduceDependencies filters dependency-backed candidate slices in place on the +// provided Retrieved. Callers should assume retrieved.Candidates is modified. +func reduceDependencies(retrieved *Retrieved, scopes scopeSet) { + if retrieved == nil { + return + } + + retrieved.Candidates.Actions = reduceActions(scopes, retrieved.Candidates) + retrieved.Candidates.SubjectConditionSets = reduceSubjectConditionSets(scopes, retrieved.Candidates) +} + +func reduceActions(scopes scopeSet, candidates Candidates) []*policy.Action { + if !scopes.requiresActions() || scopes.has(ScopeActions) { + return candidates.Actions + } + + required := make(map[string]struct{}) + + if scopes.has(ScopeSubjectMappings) { + for _, mapping := range candidates.SubjectMappings { + for _, action := range mapping.GetActions() { + if id := action.GetId(); id != "" { + required[id] = struct{}{} + } + } + } + } + + if scopes.has(ScopeRegisteredResources) { + for _, resource := range candidates.RegisteredResources { + if _, ok := registeredResourceNamespaceRef(resource); !ok { + continue + } + for _, value := range resource.GetValues() { + for _, aav := range value.GetActionAttributeValues() { + if id := aav.GetAction().GetId(); id != "" { + required[id] = struct{}{} + } + } + } + } + } + + if scopes.has(ScopeObligationTriggers) { + for _, trigger := range candidates.ObligationTriggers { + if id := trigger.GetAction().GetId(); id != "" { + required[id] = struct{}{} + } + } + } + + return filterActions(candidates.Actions, required) +} + +// Determine if a registered resource can be derived to one namespace based on +// whether or not the attribute values are all under one namespace. +func registeredResourceNamespaceRef(resource *policy.RegisteredResource) (*policy.Namespace, bool) { + if resource == nil { + return nil, false + } + + var observed *policy.Namespace + for _, value := range resource.GetValues() { + for _, aav := range value.GetActionAttributeValues() { + namespace := namespaceFromAttributeValue(aav.GetAttributeValue()) + if namespaceRefKey(namespace) == "" { + return nil, false + } + if observed == nil { + observed = namespace + continue + } + if !sameNamespace(observed, namespace) { + return nil, false + } + } + } + + return observed, observed != nil +} + +func reduceSubjectConditionSets(scopes scopeSet, candidates Candidates) []*policy.SubjectConditionSet { + if !scopes.requiresSubjectConditionSets() || scopes.has(ScopeSubjectConditionSets) { + return candidates.SubjectConditionSets + } + + required := make(map[string]struct{}) + for _, mapping := range candidates.SubjectMappings { + if id := mapping.GetSubjectConditionSet().GetId(); id != "" { + required[id] = struct{}{} + } + } + + return filterSubjectConditionSets(candidates.SubjectConditionSets, required) +} + +func filterActions(actions []*policy.Action, required map[string]struct{}) []*policy.Action { + if len(required) == 0 { + return nil + } + + filtered := make([]*policy.Action, 0, len(actions)) + for _, action := range actions { + if action == nil { + continue + } + if _, ok := required[action.GetId()]; ok { + filtered = append(filtered, action) + } + } + + return filtered +} + +func filterSubjectConditionSets(sets []*policy.SubjectConditionSet, required map[string]struct{}) []*policy.SubjectConditionSet { + if len(required) == 0 { + return nil + } + + filtered := make([]*policy.SubjectConditionSet, 0, len(sets)) + for _, scs := range sets { + if scs == nil { + continue + } + if _, ok := required[scs.GetId()]; ok { + filtered = append(filtered, scs) + } + } + + return filtered +} diff --git a/otdfctl/migrations/namespacedpolicy/reduce_test.go b/otdfctl/migrations/namespacedpolicy/reduce_test.go new file mode 100644 index 0000000000..be55fae96e --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/reduce_test.go @@ -0,0 +1,158 @@ +package namespacedpolicy + +import ( + "testing" + + "github.com/opentdf/platform/protocol/go/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReduceDependenciesKeepsOnlySubjectMappingDependencies(t *testing.T) { + t.Parallel() + + scopes, err := normalizeScopes([]Scope{ScopeSubjectMappings}) + require.NoError(t, err) + + retrieved := &Retrieved{ + Candidates: Candidates{ + Actions: []*policy.Action{ + {Id: "action-keep", Name: "decrypt"}, + {Id: "action-drop", Name: "upload"}, + }, + SubjectConditionSets: []*policy.SubjectConditionSet{ + {Id: "scs-keep"}, + {Id: "scs-drop"}, + }, + SubjectMappings: []*policy.SubjectMapping{ + { + Id: "mapping-1", + Actions: []*policy.Action{ + {Id: "action-keep"}, + }, + SubjectConditionSet: &policy.SubjectConditionSet{Id: "scs-keep"}, + }, + }, + }, + } + + reduceDependencies(retrieved, scopes) + + require.Len(t, retrieved.Candidates.Actions, 1) + assert.Equal(t, "action-keep", retrieved.Candidates.Actions[0].GetId()) + require.Len(t, retrieved.Candidates.SubjectConditionSets, 1) + assert.Equal(t, "scs-keep", retrieved.Candidates.SubjectConditionSets[0].GetId()) +} + +func TestReduceActionsIgnoresRegisteredResourcesWithoutSingleNamespace(t *testing.T) { + t.Parallel() + + scopes, err := normalizeScopes([]Scope{ScopeRegisteredResources}) + require.NoError(t, err) + + resourceWithSingleNamespace := testRegisteredResource( + "resource-keep", + "keep", + testRegisteredResourceValue( + "value-1", + testActionAttributeValue( + "action-keep", + "decrypt", + testAttributeValue("https://example.com/attr/classification/value/secret", testNamespace("https://example.com")), + ), + ), + ) + resourceWithConflictingNamespaces := testRegisteredResource( + "resource-drop", + "drop", + testRegisteredResourceValue( + "value-2", + testActionAttributeValue( + "action-drop", + "decrypt", + testAttributeValue("https://example.com/attr/classification/value/secret", testNamespace("https://example.com")), + ), + ), + testRegisteredResourceValue( + "value-3", + testActionAttributeValue( + "action-drop", + "decrypt", + testAttributeValue("https://other.example.com/attr/classification/value/secret", testNamespace("https://other.example.com")), + ), + ), + ) + + actions := reduceActions(scopes, Candidates{ + Actions: []*policy.Action{ + {Id: "action-keep", Name: "decrypt"}, + {Id: "action-drop", Name: "decrypt"}, + }, + RegisteredResources: []*policy.RegisteredResource{ + resourceWithSingleNamespace, + resourceWithConflictingNamespaces, + }, + }) + + require.Len(t, actions, 1) + assert.Equal(t, "action-keep", actions[0].GetId()) +} + +func TestRegisteredResourceNamespaceRefAcceptsEquivalentFQNs(t *testing.T) { + t.Parallel() + + resource := testRegisteredResource( + "resource-1", + "resource", + testRegisteredResourceValue( + "value-1", + testActionAttributeValue( + "action-1", + "read", + testAttributeValue("", testNamespace(" https://Example.COM ")), + ), + ), + testRegisteredResourceValue( + "value-2", + testActionAttributeValue( + "action-2", + "read", + testAttributeValue("", testNamespace("https://example.com")), + ), + ), + ) + + namespace, ok := registeredResourceNamespaceRef(resource) + require.True(t, ok) + require.NotNil(t, namespace) + assert.Equal(t, " https://Example.COM ", namespace.GetFqn()) +} + +func TestReduceActionsKeepsOnlyObligationTriggerDependencies(t *testing.T) { + t.Parallel() + + scopes, err := normalizeScopes([]Scope{ScopeObligationTriggers}) + require.NoError(t, err) + + actions := reduceActions(scopes, Candidates{ + Actions: []*policy.Action{ + {Id: "action-keep", Name: "decrypt"}, + {Id: "action-drop", Name: "upload"}, + }, + ObligationTriggers: []*policy.ObligationTrigger{ + { + Id: "trigger-1", + Action: &policy.Action{ + Id: "action-keep", + }, + }, + { + Id: "trigger-2", + Action: &policy.Action{}, + }, + }, + }) + + require.Len(t, actions, 1) + assert.Equal(t, "action-keep", actions[0].GetId()) +} diff --git a/otdfctl/migrations/namespacedpolicy/registered_resources_execute.go b/otdfctl/migrations/namespacedpolicy/registered_resources_execute.go new file mode 100644 index 0000000000..3c8703ca68 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/registered_resources_execute.go @@ -0,0 +1,255 @@ +package namespacedpolicy + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/registeredresources" +) + +func (e *Executor) executeRegisteredResources(ctx context.Context, plans []*RegisteredResourcePlan) error { + if len(plans) == 0 { + return nil + } + + for _, plan := range plans { + if plan == nil || plan.Source == nil { + continue + } + + for _, target := range plan.Targets { + if target == nil { + continue + } + + if err := e.executeRegisteredResourceTarget(ctx, plan, target); err != nil { + return err + } + } + } + + return nil +} + +func (e *Executor) executeRegisteredResourceTarget(ctx context.Context, plan *RegisteredResourcePlan, target *RegisteredResourceTargetPlan) error { + switch target.Status { + case TargetStatusAlreadyMigrated: + if target.TargetID() == "" { + return fmt.Errorf("%w: registered resource %q target %q", ErrMissingMigratedTarget, plan.Source.GetId(), namespaceLabel(target.Namespace)) + } + return nil + case TargetStatusCreate: + return e.createRegisteredResourceTarget(ctx, plan, target) + case TargetStatusExistingStandard: + return fmt.Errorf("%w: registered resource %q target %q has unsupported status %q", ErrUnsupportedStatus, plan.Source.GetId(), namespaceLabel(target.Namespace), target.Status) + case TargetStatusUnresolved: + return fmt.Errorf("%w: registered resource %q target %q is unresolved: %s", ErrPlanNotExecutable, plan.Source.GetId(), namespaceLabel(target.Namespace), target.Reason) + default: + return fmt.Errorf("%w: registered resource %q target %q has unsupported status %q", ErrUnsupportedStatus, plan.Source.GetId(), namespaceLabel(target.Namespace), target.Status) + } +} + +func (e *Executor) createRegisteredResourceTarget(ctx context.Context, plan *RegisteredResourcePlan, target *RegisteredResourceTargetPlan) error { + namespace := namespaceIdentifier(target.Namespace) + if namespace == "" { + return fmt.Errorf("%w: registered resource %q", ErrTargetNamespaceRequired, plan.Source.GetId()) + } + + // Create the parent RR only when the plan did not already select an existing + // target RR to reuse for this namespace. + created := target.Existing + if created == nil { + var err error + created, err = e.handler.CreateRegisteredResource( + ctx, + namespace, + plan.Source.GetName(), + nil, + metadataForCreate( + plan.Source.GetId(), + metadataLabels(plan.Source.GetMetadata()), + e.runID, + ), + ) + if err != nil { + target.Execution = &ExecutionResult{ + RunID: e.runID, + Failure: err.Error(), + } + return fmt.Errorf("%w: create registered resource %q in namespace %q", err, plan.Source.GetId(), namespaceLabel(target.Namespace)) + } + } + if created == nil { + target.Execution = &ExecutionResult{ + RunID: e.runID, + Failure: ErrMissingCreatedTargetID.Error(), + } + return fmt.Errorf("%w: registered resource %q target %q", ErrMissingCreatedTargetID, plan.Source.GetId(), namespaceLabel(target.Namespace)) + } + if created.GetId() == "" { + target.Execution = &ExecutionResult{ + RunID: e.runID, + Failure: ErrMissingCreatedTargetID.Error(), + } + return fmt.Errorf("%w: registered resource %q target %q", ErrMissingCreatedTargetID, plan.Source.GetId(), namespaceLabel(target.Namespace)) + } + + target.Execution = &ExecutionResult{ + RunID: e.runID, + Applied: true, + CreatedTargetID: created.GetId(), + } + + existingValues := registeredResourceValueIDsByValue(created) + for _, valuePlan := range target.Values { + if valuePlan == nil || valuePlan.Source == nil { + continue + } + + // RR values are reconciled at runtime so explicit parent reuse can skip + // values that already exist on the chosen parent RR. + if existingID := existingValues[registeredResourceValueKey(valuePlan.Source.GetValue())]; existingID != "" { + valuePlan.Execution = &ExecutionResult{ + RunID: e.runID, + Applied: true, + CreatedTargetID: existingID, + } + continue + } + + if err := e.createRegisteredResourceValue(ctx, target, valuePlan); err != nil { + return err + } + } + + return nil +} + +func (e *Executor) createRegisteredResourceValue(ctx context.Context, target *RegisteredResourceTargetPlan, valuePlan *RegisteredResourceValuePlan) error { + actionAttributeValues, err := e.registeredResourceActionAttributeValues(target.Namespace, valuePlan) + if err != nil { + valuePlan.Execution = &ExecutionResult{ + RunID: e.runID, + Failure: err.Error(), + } + return fmt.Errorf("%w: build registered resource value %q action bindings for namespace %q", err, valuePlan.Source.GetId(), namespaceLabel(target.Namespace)) + } + + created, err := e.handler.CreateRegisteredResourceValue( + ctx, + target.TargetID(), + valuePlan.Source.GetValue(), + actionAttributeValues, + metadataForCreate( + valuePlan.Source.GetId(), + metadataLabels(valuePlan.Source.GetMetadata()), + e.runID, + ), + ) + if err != nil { + valuePlan.Execution = &ExecutionResult{ + RunID: e.runID, + Failure: err.Error(), + } + return fmt.Errorf("%w: create registered resource value %q for resource %q in namespace %q", err, valuePlan.Source.GetId(), target.TargetID(), namespaceLabel(target.Namespace)) + } + if created.GetId() == "" { + valuePlan.Execution = &ExecutionResult{ + RunID: e.runID, + Failure: ErrMissingCreatedTargetID.Error(), + } + return fmt.Errorf("%w: registered resource value %q for target %q", ErrMissingCreatedTargetID, valuePlan.Source.GetId(), namespaceLabel(target.Namespace)) + } + + valuePlan.Execution = &ExecutionResult{ + RunID: e.runID, + Applied: true, + CreatedTargetID: created.GetId(), + } + + return nil +} + +func (e *Executor) registeredResourceActionAttributeValues(namespace *policy.Namespace, valuePlan *RegisteredResourceValuePlan) ([]*registeredresources.ActionAttributeValue, error) { + if valuePlan == nil { + return nil, nil + } + + actionAttributeValues := make([]*registeredresources.ActionAttributeValue, 0, len(valuePlan.ActionBindings)) + for _, binding := range valuePlan.ActionBindings { + if binding == nil { + continue + } + + if binding.SourceActionID == "" { + return nil, fmt.Errorf("%w: action source id is missing", ErrPlanNotExecutable) + } + + actionID := e.cachedActionTargetID(binding.SourceActionID, namespace) + if actionID == "" { + return nil, fmt.Errorf("%w: action %q target %q", ErrMissingMigratedTarget, binding.SourceActionID, namespaceLabel(namespace)) + } + + actionAttributeValue, err := registeredResourceActionAttributeValue(actionID, binding.AttributeValue) + if err != nil { + return nil, fmt.Errorf("registered resource value %q binding action %q: %w", valuePlan.Source.GetId(), binding.SourceActionID, err) + } + actionAttributeValues = append(actionAttributeValues, actionAttributeValue) + } + + return actionAttributeValues, nil +} + +func registeredResourceActionAttributeValue(actionID string, attributeValue *policy.Value) (*registeredresources.ActionAttributeValue, error) { + if strings.TrimSpace(actionID) == "" { + return nil, errors.New("action target id is empty") + } + if attributeValue == nil { + return nil, errors.New("attribute value is missing") + } + + actionAttributeValue := ®isteredresources.ActionAttributeValue{ + ActionIdentifier: ®isteredresources.ActionAttributeValue_ActionId{ + ActionId: actionID, + }, + } + + if attributeValueID := strings.TrimSpace(attributeValue.GetId()); attributeValueID != "" { + actionAttributeValue.AttributeValueIdentifier = ®isteredresources.ActionAttributeValue_AttributeValueId{ + AttributeValueId: attributeValueID, + } + return actionAttributeValue, nil + } + + if attributeValueFQN := strings.TrimSpace(attributeValue.GetFqn()); attributeValueFQN != "" { + actionAttributeValue.AttributeValueIdentifier = ®isteredresources.ActionAttributeValue_AttributeValueFqn{ + AttributeValueFqn: attributeValueFQN, + } + return actionAttributeValue, nil + } + + return nil, errors.New("attribute value identifier is missing") +} + +func registeredResourceValueIDsByValue(resource *policy.RegisteredResource) map[string]string { + valueIDs := make(map[string]string) + if resource == nil { + return valueIDs + } + + for _, value := range resource.GetValues() { + if value == nil { + continue + } + valueIDs[registeredResourceValueKey(value.GetValue())] = value.GetId() + } + + return valueIDs +} + +func registeredResourceValueKey(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} diff --git a/otdfctl/migrations/namespacedpolicy/registered_resources_execute_test.go b/otdfctl/migrations/namespacedpolicy/registered_resources_execute_test.go new file mode 100644 index 0000000000..7bba2e388c --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/registered_resources_execute_test.go @@ -0,0 +1,530 @@ +package namespacedpolicy + +import ( + "errors" + "testing" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecuteRegisteredResources(t *testing.T) { + t.Parallel() + + namespace1 := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + errBoom := errors.New("boom") + + tests := []struct { + name string + plan *Plan + handler *mockExecutorHandler + runID string + wantErr *expectedError + assert func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, plan *Plan) + }{ + { + name: "creates registered resource shell and values with authoritative action target ids", + plan: &Plan{ + Scopes: []Scope{ScopeActions, ScopeRegisteredResources}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{Id: "action-1", Name: "read"}, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + { + Source: &policy.Action{Id: "action-2", Name: "standard-read"}, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusExistingStandard, + Existing: &policy.Action{Id: "existing-standard-action-2", Name: "standard-read"}, + }, + }, + }, + }, + RegisteredResources: []*RegisteredResourcePlan{ + { + Source: &policy.RegisteredResource{ + Id: "rr-1", + Name: "repo", + Metadata: &common.Metadata{ + Labels: map[string]string{ + "owner": "policy-team", + }, + }, + }, + Targets: []*RegisteredResourceTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + Values: []*RegisteredResourceValuePlan{ + { + Source: &policy.RegisteredResourceValue{ + Id: "rrv-1", + Value: "repo-a", + Metadata: &common.Metadata{ + Labels: map[string]string{ + "classification": "secret", + }, + }, + }, + ActionBindings: []*RegisteredResourceActionBinding{ + { + SourceActionID: "action-1", + AttributeValue: &policy.Value{ + Id: "attribute-value-id-1", + Fqn: "https://example.com/attr/classification/value/secret", + }, + ActionTargetRef: &ActionBinding{ + SourceID: "action-1", + Namespace: namespace1, + Status: TargetStatusCreate, + TargetID: "stale-created-action-id", + }, + }, + { + SourceActionID: "action-2", + AttributeValue: &policy.Value{ + Fqn: "https://example.com/attr/project/value/apollo", + }, + ActionTargetRef: &ActionBinding{ + SourceID: "action-2", + Namespace: namespace1, + Status: TargetStatusExistingStandard, + TargetID: "stale-standard-action-id", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{ + results: map[string]map[string]*policy.Action{ + "read": { + "ns-1": {Id: "created-action-1", Name: "read"}, + }, + }, + registeredResourceResult: map[string]map[string]*policy.RegisteredResource{ + "rr-1": { + "ns-1": {Id: "created-rr-1", Name: "repo"}, + }, + }, + registeredResourceValueResult: map[string]map[string]*policy.RegisteredResourceValue{ + "rrv-1": { + "created-rr-1": {Id: "created-rrv-1", Value: "repo-a"}, + }, + }, + }, + runID: "run-rr-123", + assert: func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.NoError(t, err) + + require.Contains(t, handler.createdRegisteredResources, "rr-1") + require.Contains(t, handler.createdRegisteredResources["rr-1"], "ns-1") + resourceCall := handler.createdRegisteredResources["rr-1"]["ns-1"] + assert.Equal(t, "repo", resourceCall.Name) + assert.Equal(t, "ns-1", resourceCall.Namespace) + assert.Empty(t, resourceCall.Values) + assert.Equal(t, map[string]string{ + "owner": "policy-team", + migrationLabelMigratedFrom: "rr-1", + migrationLabelRun: "run-rr-123", + }, resourceCall.Metadata.GetLabels()) + + require.Contains(t, handler.createdRegisteredResourceValues, "rrv-1") + require.Contains(t, handler.createdRegisteredResourceValues["rrv-1"], "created-rr-1") + valueCall := handler.createdRegisteredResourceValues["rrv-1"]["created-rr-1"] + assert.Equal(t, "created-rr-1", valueCall.ResourceID) + assert.Equal(t, "repo-a", valueCall.Value) + assert.Equal(t, map[string]string{ + "classification": "secret", + migrationLabelMigratedFrom: "rrv-1", + migrationLabelRun: "run-rr-123", + }, valueCall.Metadata.GetLabels()) + require.Len(t, valueCall.ActionAttributeValues, 2) + assert.Equal(t, "created-action-1", valueCall.ActionAttributeValues[0].GetActionId()) + assert.Equal(t, "attribute-value-id-1", valueCall.ActionAttributeValues[0].GetAttributeValueId()) + assert.Equal(t, "existing-standard-action-2", valueCall.ActionAttributeValues[1].GetActionId()) + assert.Equal(t, "https://example.com/attr/project/value/apollo", valueCall.ActionAttributeValues[1].GetAttributeValueFqn()) + + resourceTarget := plan.RegisteredResources[0].Targets[0] + require.NotNil(t, resourceTarget.Execution) + assert.True(t, resourceTarget.Execution.Applied) + assert.Equal(t, "created-rr-1", resourceTarget.Execution.CreatedTargetID) + assert.Equal(t, "run-rr-123", resourceTarget.Execution.RunID) + assert.Equal(t, "created-rr-1", resourceTarget.TargetID()) + + valueTarget := plan.RegisteredResources[0].Targets[0].Values[0] + require.NotNil(t, valueTarget.Execution) + assert.True(t, valueTarget.Execution.Applied) + assert.Equal(t, "created-rrv-1", valueTarget.Execution.CreatedTargetID) + assert.Equal(t, "run-rr-123", valueTarget.Execution.RunID) + assert.Equal(t, "created-rrv-1", valueTarget.TargetID()) + + assert.Equal(t, "created-action-1", executor.cachedActionTargetID("action-1", namespace1)) + assert.Equal(t, "existing-standard-action-2", executor.cachedActionTargetID("action-2", namespace1)) + }, + }, + { + name: "skips already migrated registered resource targets", + plan: &Plan{ + Scopes: []Scope{ScopeActions, ScopeRegisteredResources}, + RegisteredResources: []*RegisteredResourcePlan{ + { + Source: &policy.RegisteredResource{Id: "rr-1", Name: "repo"}, + Targets: []*RegisteredResourceTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + Existing: &policy.RegisteredResource{Id: "migrated-rr-1", Name: "repo"}, + Values: []*RegisteredResourceValuePlan{ + { + Source: &policy.RegisteredResourceValue{Id: "rrv-1", Value: "repo-a"}, + }, + }, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.NoError(t, err) + assert.Nil(t, handler.createdRegisteredResources) + assert.Nil(t, handler.createdRegisteredResourceValues) + assert.Equal(t, "migrated-rr-1", plan.RegisteredResources[0].Targets[0].TargetID()) + assert.Nil(t, plan.RegisteredResources[0].Targets[0].Execution) + assert.Nil(t, plan.RegisteredResources[0].Targets[0].Values[0].Execution) + }, + }, + { + name: "returns not executable for unresolved target status", + plan: &Plan{ + Scopes: []Scope{ScopeActions, ScopeRegisteredResources}, + RegisteredResources: []*RegisteredResourcePlan{ + { + Source: &policy.RegisteredResource{Id: "rr-1", Name: "repo"}, + Targets: []*RegisteredResourceTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusUnresolved, + Reason: ErrDuplicateCanonicalMatch.Error(), + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError( + ErrPlanNotExecutable, + `registered resource %q target %q is unresolved: %s`, + "rr-1", + namespace1.GetFqn(), + ErrDuplicateCanonicalMatch, + ), + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Nil(t, handler.createdRegisteredResources) + assert.Nil(t, handler.createdRegisteredResourceValues) + }, + }, + { + name: "reuses existing registered resource parent on create targets", + plan: &Plan{ + Scopes: []Scope{ScopeActions, ScopeRegisteredResources}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{Id: "action-1", Name: "read"}, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + RegisteredResources: []*RegisteredResourcePlan{ + { + Source: &policy.RegisteredResource{Id: "rr-1", Name: "repo"}, + Targets: []*RegisteredResourceTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + Existing: &policy.RegisteredResource{ + Id: "existing-rr-1", + Name: "repo", + Values: []*policy.RegisteredResourceValue{ + {Id: "existing-rrv-1", Value: "repo-a"}, + }, + }, + Values: []*RegisteredResourceValuePlan{ + { + Source: &policy.RegisteredResourceValue{Id: "rrv-1", Value: "repo-a"}, + ActionBindings: []*RegisteredResourceActionBinding{ + { + SourceActionID: "action-1", + AttributeValue: &policy.Value{Id: "attribute-value-id-1"}, + }, + }, + }, + { + Source: &policy.RegisteredResourceValue{Id: "rrv-2", Value: "repo-b"}, + ActionBindings: []*RegisteredResourceActionBinding{ + { + SourceActionID: "action-1", + AttributeValue: &policy.Value{Id: "attribute-value-id-2"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{ + results: map[string]map[string]*policy.Action{ + "read": { + "ns-1": {Id: "created-action-1", Name: "read"}, + }, + }, + registeredResourceValueResult: map[string]map[string]*policy.RegisteredResourceValue{ + "rrv-2": { + "existing-rr-1": {Id: "created-rrv-2", Value: "repo-b"}, + }, + }, + }, + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.NoError(t, err) + assert.Nil(t, handler.createdRegisteredResources) + require.Contains(t, handler.createdRegisteredResourceValues, "rrv-2") + require.Contains(t, handler.createdRegisteredResourceValues["rrv-2"], "existing-rr-1") + assert.NotContains(t, handler.createdRegisteredResourceValues, "rrv-1") + + target := plan.RegisteredResources[0].Targets[0] + require.NotNil(t, target.Execution) + assert.True(t, target.Execution.Applied) + assert.Equal(t, "existing-rr-1", target.Execution.CreatedTargetID) + assert.Equal(t, "existing-rr-1", target.TargetID()) + + existingValue := target.Values[0] + require.NotNil(t, existingValue.Execution) + assert.True(t, existingValue.Execution.Applied) + assert.Equal(t, "existing-rrv-1", existingValue.Execution.CreatedTargetID) + + createdValue := target.Values[1] + require.NotNil(t, createdValue.Execution) + assert.True(t, createdValue.Execution.Applied) + assert.Equal(t, "created-rrv-2", createdValue.Execution.CreatedTargetID) + }, + }, + { + name: "records shell creation failures on the target", + plan: &Plan{ + Scopes: []Scope{ScopeActions, ScopeRegisteredResources}, + RegisteredResources: []*RegisteredResourcePlan{ + { + Source: &policy.RegisteredResource{Id: "rr-1", Name: "repo"}, + Targets: []*RegisteredResourceTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{ + registeredResourceErrs: map[string]map[string]error{ + "rr-1": { + "ns-1": errBoom, + }, + }, + }, + wantErr: wantError(errBoom, `create registered resource %q in namespace %q`, "rr-1", namespace1.GetFqn()), + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.Error(t, err) + require.Contains(t, handler.createdRegisteredResources, "rr-1") + require.NotNil(t, plan.RegisteredResources[0].Targets[0].Execution) + assert.Equal(t, "boom", plan.RegisteredResources[0].Targets[0].Execution.Failure) + }, + }, + { + name: "stops when a registered resource value cannot resolve its migrated action target", + plan: &Plan{ + Scopes: []Scope{ScopeActions, ScopeRegisteredResources}, + RegisteredResources: []*RegisteredResourcePlan{ + { + Source: &policy.RegisteredResource{Id: "rr-1", Name: "repo"}, + Targets: []*RegisteredResourceTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + Values: []*RegisteredResourceValuePlan{ + { + Source: &policy.RegisteredResourceValue{Id: "rrv-1", Value: "repo-a"}, + ActionBindings: []*RegisteredResourceActionBinding{ + { + SourceActionID: "missing-action", + AttributeValue: &policy.Value{Id: "attribute-value-id-1"}, + ActionTargetRef: &ActionBinding{ + SourceID: "missing-action", + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{ + registeredResourceResult: map[string]map[string]*policy.RegisteredResource{ + "rr-1": { + "ns-1": {Id: "created-rr-1", Name: "repo"}, + }, + }, + }, + wantErr: wantError( + ErrMissingMigratedTarget, + `action %q target %q: build registered resource value %q action bindings for namespace %q`, + "missing-action", + namespace1.GetFqn(), + "rrv-1", + namespace1.GetFqn(), + ), + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.Error(t, err) + require.Contains(t, handler.createdRegisteredResources, "rr-1") + assert.Nil(t, handler.createdRegisteredResourceValues) + require.NotNil(t, plan.RegisteredResources[0].Targets[0].Execution) + assert.True(t, plan.RegisteredResources[0].Targets[0].Execution.Applied) + require.NotNil(t, plan.RegisteredResources[0].Targets[0].Values[0].Execution) + assert.Contains(t, plan.RegisteredResources[0].Targets[0].Values[0].Execution.Failure, `missing migrated target: action "missing-action" target "https://example.com"`) + }, + }, + { + name: "records value creation failures on the value target", + plan: &Plan{ + Scopes: []Scope{ScopeActions, ScopeRegisteredResources}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{Id: "action-1", Name: "read"}, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + RegisteredResources: []*RegisteredResourcePlan{ + { + Source: &policy.RegisteredResource{Id: "rr-1", Name: "repo"}, + Targets: []*RegisteredResourceTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + Values: []*RegisteredResourceValuePlan{ + { + Source: &policy.RegisteredResourceValue{Id: "rrv-1", Value: "repo-a"}, + ActionBindings: []*RegisteredResourceActionBinding{ + { + SourceActionID: "action-1", + AttributeValue: &policy.Value{Id: "attribute-value-id-1"}, + ActionTargetRef: &ActionBinding{ + SourceID: "action-1", + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{ + results: map[string]map[string]*policy.Action{ + "read": { + "ns-1": {Id: "created-action-1", Name: "read"}, + }, + }, + registeredResourceResult: map[string]map[string]*policy.RegisteredResource{ + "rr-1": { + "ns-1": {Id: "created-rr-1", Name: "repo"}, + }, + }, + registeredResourceValueErrs: map[string]map[string]error{ + "rrv-1": { + "created-rr-1": errBoom, + }, + }, + }, + wantErr: wantError(errBoom, `create registered resource value %q for resource %q in namespace %q`, "rrv-1", "created-rr-1", namespace1.GetFqn()), + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.Error(t, err) + require.Contains(t, handler.createdRegisteredResourceValues, "rrv-1") + require.NotNil(t, plan.RegisteredResources[0].Targets[0].Execution) + assert.True(t, plan.RegisteredResources[0].Targets[0].Execution.Applied) + require.NotNil(t, plan.RegisteredResources[0].Targets[0].Values[0].Execution) + assert.Equal(t, "boom", plan.RegisteredResources[0].Targets[0].Values[0].Execution.Failure) + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + executor, err := NewExecutor(tt.handler) + require.NoError(t, err) + if tt.runID != "" { + executor.runID = tt.runID + } + + err = executor.Execute(t.Context(), tt.plan) + switch { + case tt.wantErr != nil: + require.Error(t, err) + require.ErrorIs(t, err, tt.wantErr.is) + require.EqualError(t, err, tt.wantErr.message) + default: + require.NoError(t, err) + } + + tt.assert(t, err, executor, tt.handler, tt.plan) + }) + } +} diff --git a/otdfctl/migrations/namespacedpolicy/resolved.go b/otdfctl/migrations/namespacedpolicy/resolved.go new file mode 100644 index 0000000000..7cea70961f --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/resolved.go @@ -0,0 +1,552 @@ +package namespacedpolicy + +import ( + "errors" + "fmt" + "strings" + + "github.com/opentdf/platform/protocol/go/policy" +) + +type ResolvedTargets struct { + Scopes []Scope + Actions []*ResolvedAction + SubjectConditionSets []*ResolvedSubjectConditionSet + SubjectMappings []*ResolvedSubjectMapping + RegisteredResources []*ResolvedRegisteredResource + ObligationTriggers []*ResolvedObligationTrigger +} + +type ResolvedAction struct { + Source *policy.Action + References []*ActionReference + Results []*ResolvedActionResult +} + +type ResolvedActionResult struct { + Namespace *policy.Namespace + AlreadyMigrated *policy.Action + ExistingStandard *policy.Action + NeedsCreate bool +} + +type ResolvedSubjectConditionSet struct { + Source *policy.SubjectConditionSet + Results []*ResolvedSubjectConditionSetResult +} + +type ResolvedSubjectConditionSetResult struct { + Namespace *policy.Namespace + AlreadyMigrated *policy.SubjectConditionSet + NeedsCreate bool +} + +type ResolvedSubjectMapping struct { + Source *policy.SubjectMapping + Namespace *policy.Namespace + AlreadyMigrated *policy.SubjectMapping + NeedsCreate bool +} + +type ResolvedRegisteredResource struct { + Source *policy.RegisteredResource + Namespace *policy.Namespace + AlreadyMigrated *policy.RegisteredResource + NeedsCreate bool + Unresolved *Unresolved +} + +type ResolvedObligationTrigger struct { + Source *policy.ObligationTrigger + Namespace *policy.Namespace + AlreadyMigrated *policy.ObligationTrigger + NeedsCreate bool +} + +type resolver struct { + derived *DerivedTargets + existing *ExistingTargets + scopes scopeSet + actionResultsByKey map[string]*ResolvedActionResult + scsResultsByKey map[string]*ResolvedSubjectConditionSetResult +} + +// resolveExisting classifies each derived source/target placement as already +// migrated, satisfied by an existing target object, needing creation, or still +// unresolved. This is the phase that ties the derived namespace targets to live +// target-side state before the final per-namespace plan is built. +func resolveExisting(derived *DerivedTargets, existing *ExistingTargets) (*ResolvedTargets, error) { + if existing == nil { + existing = newExistingTargets() + } + + r := &resolver{ + derived: derived, + existing: existing, + scopes: scopesFromSlice(derived.Scopes), + actionResultsByKey: make(map[string]*ResolvedActionResult), + scsResultsByKey: make(map[string]*ResolvedSubjectConditionSetResult), + } + + resolvedActions, err := r.resolveActions() + if err != nil { + return nil, err + } + resolvedSubjectConditionSets, err := r.resolveSubjectConditionSets() + if err != nil { + return nil, err + } + resolvedSubjectMappings, err := r.resolveSubjectMappings() + if err != nil { + return nil, err + } + resolvedRegisteredResources, err := r.resolveRegisteredResources() + if err != nil { + return nil, err + } + resolvedObligationTriggers, err := r.resolveObligationTriggers() + if err != nil { + return nil, err + } + + return &ResolvedTargets{ + Scopes: append([]Scope(nil), derived.Scopes...), + Actions: resolvedActions, + SubjectConditionSets: resolvedSubjectConditionSets, + SubjectMappings: resolvedSubjectMappings, + RegisteredResources: resolvedRegisteredResources, + ObligationTriggers: resolvedObligationTriggers, + }, nil +} + +func (r *resolver) resolveActions() ([]*ResolvedAction, error) { + if r == nil || r.derived == nil { + return nil, nil + } + + resolved := make([]*ResolvedAction, 0, len(r.derived.Actions)) + for _, action := range r.derived.Actions { + item, err := r.resolveAction(action) + if err != nil { + return nil, err + } + resolved = append(resolved, item) + } + return resolved, nil +} + +func (r *resolver) resolveAction(derived *DerivedAction) (*ResolvedAction, error) { + if derived == nil || derived.Source == nil { + return nil, fmt.Errorf("%w: empty action candidate", ErrUndeterminedTargetMapping) + } + + item := &ResolvedAction{ + Source: derived.Source, + References: append([]*ActionReference(nil), derived.References...), + Results: make([]*ResolvedActionResult, 0, len(derived.Targets)), + } + for _, namespace := range derived.Targets { + result, err := r.resolveActionTargetFromExisting(derived.Source, namespace) + if err != nil { + return nil, fmt.Errorf("action %q in namespace %q: %w", derived.Source.GetId(), namespace.GetId(), err) + } + item.Results = append(item.Results, result) + r.addActionResult(derived.Source.GetId(), result) + } + + return item, nil +} + +func (r *resolver) resolveActionTargetFromExisting(source *policy.Action, namespace *policy.Namespace) (*ResolvedActionResult, error) { + if namespace.GetId() == "" { + return nil, fmt.Errorf("%w: empty namespace reference", ErrUndeterminedTargetMapping) + } + + result := &ResolvedActionResult{Namespace: namespace} + if r.isStandardAction(source) { + return r.resolveStandardActionTarget(source, namespace) + } + + existing, found, err := resolveExistingAction(source, r.existing.CustomActions[namespace.GetId()]) + switch { + case found: + result.AlreadyMigrated = existing + return result, nil + case err != nil: + return nil, err + } + + result.NeedsCreate = true + return result, nil +} + +func (r *resolver) resolveStandardActionTarget(source *policy.Action, namespace *policy.Namespace) (*ResolvedActionResult, error) { + result := &ResolvedActionResult{Namespace: namespace} + + matches := make([]*policy.Action, 0, 1) + for _, action := range r.existing.StandardActions[namespace.GetId()] { + if !actionCanonicalEqual(source, action) { + continue + } + matches = append(matches, action) + } + + switch len(matches) { + case 1: + result.ExistingStandard = matches[0] + case 0: + return nil, errors.New("matching standard action not found in target namespace") + default: + return nil, errors.New("multiple standard actions match in target namespace") + } + + return result, nil +} + +func (r *resolver) isStandardAction(action *policy.Action) bool { + if action.GetStandard() != policy.Action_STANDARD_ACTION_UNSPECIFIED { + return true + } + + switch strings.ToLower(strings.TrimSpace(action.GetName())) { + case "create", "read", "update", "delete": + return true + default: + return false + } +} + +func (r *resolver) resolveSubjectConditionSets() ([]*ResolvedSubjectConditionSet, error) { + if r == nil || r.derived == nil { + return nil, nil + } + + resolved := make([]*ResolvedSubjectConditionSet, 0, len(r.derived.SubjectConditionSets)) + for _, scs := range r.derived.SubjectConditionSets { + item, err := r.resolveSubjectConditionSet(scs) + if err != nil { + return nil, err + } + resolved = append(resolved, item) + } + return resolved, nil +} + +func (r *resolver) resolveSubjectConditionSet(derived *DerivedSubjectConditionSet) (*ResolvedSubjectConditionSet, error) { + if derived == nil || derived.Source == nil { + return nil, fmt.Errorf("%w: empty subject condition set candidate", ErrUndeterminedTargetMapping) + } + + item := &ResolvedSubjectConditionSet{ + Source: derived.Source, + Results: make([]*ResolvedSubjectConditionSetResult, 0, len(derived.Targets)), + } + for _, namespace := range derived.Targets { + result, err := r.resolveSubjectConditionSetTargetFromExisting(derived.Source, namespace) + if err != nil { + return nil, fmt.Errorf("subject condition set %q in namespace %q: %w", derived.Source.GetId(), namespace.GetId(), err) + } + item.Results = append(item.Results, result) + r.addSubjectConditionSetResult(derived.Source.GetId(), result) + } + + return item, nil +} + +func (r *resolver) resolveSubjectConditionSetTargetFromExisting(source *policy.SubjectConditionSet, namespace *policy.Namespace) (*ResolvedSubjectConditionSetResult, error) { + if namespace.GetId() == "" { + return nil, fmt.Errorf("%w: empty namespace reference", ErrUndeterminedTargetMapping) + } + result := &ResolvedSubjectConditionSetResult{Namespace: namespace} + existing, found, err := resolveExistingSubjectConditionSet(source, r.existing.SubjectConditionSets[namespace.GetId()]) + switch { + case found: + result.AlreadyMigrated = existing + case err != nil: + return nil, err + default: + result.NeedsCreate = true + } + + return result, nil +} + +func (r *resolver) resolveSubjectMappings() ([]*ResolvedSubjectMapping, error) { + if r == nil || r.derived == nil || !r.scopes.has(ScopeSubjectMappings) { + return nil, nil + } + + resolved := make([]*ResolvedSubjectMapping, 0, len(r.derived.SubjectMappings)) + for _, mapping := range r.derived.SubjectMappings { + item, err := r.resolveSubjectMapping(mapping) + if err != nil { + return nil, err + } + resolved = append(resolved, item) + } + return resolved, nil +} + +func (r *resolver) resolveSubjectMapping(derived *DerivedSubjectMapping) (*ResolvedSubjectMapping, error) { + if derived == nil || derived.Source == nil { + return nil, fmt.Errorf("%w: empty subject mapping candidate", ErrUndeterminedTargetMapping) + } + + if derived.Target == nil { + return nil, fmt.Errorf("%w: empty namespace reference", ErrUndeterminedTargetMapping) + } + + item := &ResolvedSubjectMapping{ + Source: derived.Source, + Namespace: derived.Target, + } + // Subject mappings are only safe to resolve once their action and subject + // condition set dependencies are themselves resolvable in the same target + // namespace. This keeps the plan graph internally consistent. + if err := r.resolveSubjectMappingDependencies(item.Source, item.Namespace); err != nil { + return nil, fmt.Errorf("subject mapping %q in namespace %q: %w", item.Source.GetId(), item.Namespace.GetId(), err) + } + + existing, found, err := resolveExistingSubjectMapping(item.Source, r.existing.SubjectMappings[item.Namespace.GetId()]) + switch { + case found: + item.AlreadyMigrated = existing + case err != nil: + return nil, fmt.Errorf("subject mapping %q in namespace %q: %w", item.Source.GetId(), item.Namespace.GetId(), err) + default: + item.NeedsCreate = true + } + + return item, nil +} + +func (r *resolver) resolveRegisteredResources() ([]*ResolvedRegisteredResource, error) { + if r == nil || r.derived == nil || !r.scopes.has(ScopeRegisteredResources) { + return nil, nil + } + + resolved := make([]*ResolvedRegisteredResource, 0, len(r.derived.RegisteredResources)) + for _, resource := range r.derived.RegisteredResources { + item, err := r.resolveRegisteredResource(resource) + if err != nil { + return nil, err + } + resolved = append(resolved, item) + } + return resolved, nil +} + +func (r *resolver) resolveRegisteredResource(derived *DerivedRegisteredResource) (*ResolvedRegisteredResource, error) { + item := &ResolvedRegisteredResource{} + if derived == nil { + return item, nil + } + + item.Source = derived.Source + item.Namespace = derived.Target + item.Unresolved = derived.Unresolved + + if item.Unresolved != nil { + return item, nil + } + if item.Source == nil { + return nil, fmt.Errorf("%w: registered resource is empty", ErrUndeterminedTargetMapping) + } + if item.Namespace == nil { + return nil, fmt.Errorf("%w: empty namespace reference", ErrUndeterminedTargetMapping) + } + existing, found, err := resolveExistingRegisteredResource(item.Source, r.existing.RegisteredResources[item.Namespace.GetId()]) + switch { + case found: + item.AlreadyMigrated = existing + case err != nil: + return nil, fmt.Errorf("registered resource %q in namespace %q: %w", item.Source.GetId(), item.Namespace.GetId(), err) + default: + item.NeedsCreate = true + } + + return item, nil +} + +func (r *resolver) resolveObligationTriggers() ([]*ResolvedObligationTrigger, error) { + if r == nil || r.derived == nil || !r.scopes.has(ScopeObligationTriggers) { + return nil, nil + } + + resolved := make([]*ResolvedObligationTrigger, 0, len(r.derived.ObligationTriggers)) + for _, trigger := range r.derived.ObligationTriggers { + item, err := r.resolveObligationTrigger(trigger) + if err != nil { + return nil, err + } + resolved = append(resolved, item) + } + return resolved, nil +} + +func (r *resolver) resolveObligationTrigger(derived *DerivedObligationTrigger) (*ResolvedObligationTrigger, error) { + if derived == nil || derived.Source == nil { + return nil, fmt.Errorf("%w: empty obligation trigger candidate", ErrUndeterminedTargetMapping) + } + if derived.Target == nil { + return nil, fmt.Errorf("%w: empty namespace reference", ErrUndeterminedTargetMapping) + } + + item := &ResolvedObligationTrigger{ + Source: derived.Source, + Namespace: derived.Target, + } + existing, found, err := resolveExistingObligationTrigger(item.Source, r.existing.ObligationTriggers[item.Namespace.GetId()]) + switch { + case found: + item.AlreadyMigrated = existing + case err != nil: + return nil, fmt.Errorf("obligation trigger %q in namespace %q: %w", item.Source.GetId(), item.Namespace.GetId(), err) + default: + item.NeedsCreate = true + } + + return item, nil +} + +func (r *resolver) addActionResult(sourceID string, result *ResolvedActionResult) { + if sourceID == "" || result == nil || result.Namespace == nil || result.Namespace.GetId() == "" { + return + } + r.actionResultsByKey[resolvedResultKey(sourceID, result.Namespace.GetId())] = result +} + +func (r *resolver) addSubjectConditionSetResult(sourceID string, result *ResolvedSubjectConditionSetResult) { + if sourceID == "" || result == nil || result.Namespace == nil || result.Namespace.GetId() == "" { + return + } + r.scsResultsByKey[resolvedResultKey(sourceID, result.Namespace.GetId())] = result +} + +func (r *resolver) resolveSubjectMappingDependencies(mapping *policy.SubjectMapping, namespace *policy.Namespace) error { + for _, action := range mapping.GetActions() { + actionID := action.GetId() + if actionID == "" { + return errors.New("subject mapping references an action without an id") + } + + result := r.actionResultsByKey[resolvedResultKey(actionID, namespace.GetId())] + if result == nil { + return fmt.Errorf("subject mapping dependency action %q is not resolved in namespace %q", actionID, namespace.GetId()) + } + } + + scsID := mapping.GetSubjectConditionSet().GetId() + if scsID == "" { + return errors.New("subject mapping references a subject condition set without an id") + } + + result := r.scsResultsByKey[resolvedResultKey(scsID, namespace.GetId())] + if result == nil { + return fmt.Errorf("subject mapping dependency subject condition set %q is not resolved in namespace %q", scsID, namespace.GetId()) + } + + return nil +} + +func resolveExistingAction(source *policy.Action, existing []*policy.Action) (*policy.Action, bool, error) { + matches := make([]*policy.Action, 0, 1) + for _, action := range existing { + if actionCanonicalEqual(source, action) { + matches = append(matches, action) + } + } + + switch len(matches) { + case 1: + return matches[0], true, nil + case 0: + return nil, false, nil + default: + return nil, false, ErrDuplicateCanonicalMatch + } +} + +func resolveExistingSubjectConditionSet(source *policy.SubjectConditionSet, existing []*policy.SubjectConditionSet) (*policy.SubjectConditionSet, bool, error) { + matches := make([]*policy.SubjectConditionSet, 0, 1) + for _, scs := range existing { + if subjectConditionSetCanonicalEqual(source, scs) { + matches = append(matches, scs) + } + } + + switch len(matches) { + case 1: + return matches[0], true, nil + case 0: + return nil, false, nil + default: + return nil, false, ErrDuplicateCanonicalMatch + } +} + +func resolveExistingSubjectMapping(source *policy.SubjectMapping, existing []*policy.SubjectMapping) (*policy.SubjectMapping, bool, error) { + matches := make([]*policy.SubjectMapping, 0, 1) + for _, mapping := range existing { + if subjectMappingCanonicalEqual(source, mapping) { + matches = append(matches, mapping) + } + } + + switch len(matches) { + case 1: + return matches[0], true, nil + case 0: + return nil, false, nil + default: + return nil, false, ErrDuplicateCanonicalMatch + } +} + +func resolveExistingRegisteredResource(source *policy.RegisteredResource, existing []*policy.RegisteredResource) (*policy.RegisteredResource, bool, error) { + matches := make([]*policy.RegisteredResource, 0, 1) + for _, resource := range existing { + if registeredResourceCanonicalEqual(source, resource) { + matches = append(matches, resource) + } + } + + switch len(matches) { + case 1: + return matches[0], true, nil + case 0: + return nil, false, nil + default: + return nil, false, ErrDuplicateCanonicalMatch + } +} + +func resolveExistingObligationTrigger(source *policy.ObligationTrigger, existing []*policy.ObligationTrigger) (*policy.ObligationTrigger, bool, error) { + matches := make([]*policy.ObligationTrigger, 0, 1) + for _, trigger := range existing { + if obligationTriggerCanonicalEqual(source, trigger) { + matches = append(matches, trigger) + } + } + + switch len(matches) { + case 1: + return matches[0], true, nil + case 0: + return nil, false, nil + default: + return nil, false, ErrDuplicateCanonicalMatch + } +} + +func resolvedResultKey(sourceID, namespaceID string) string { + return sourceID + "|" + namespaceID +} + +func scopesFromSlice(scopes []Scope) scopeSet { + set := make(scopeSet, len(scopes)) + for _, scope := range scopes { + set[scope] = struct{}{} + } + return set +} diff --git a/otdfctl/migrations/namespacedpolicy/resolved_test.go b/otdfctl/migrations/namespacedpolicy/resolved_test.go new file mode 100644 index 0000000000..b02c00e81c --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/resolved_test.go @@ -0,0 +1,163 @@ +package namespacedpolicy + +import ( + "testing" + + "github.com/opentdf/platform/protocol/go/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveExistingUsesExistingStandardAction(t *testing.T) { + t.Parallel() + + namespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + existing := newExistingTargets() + existing.StandardActions[namespace.GetId()] = []*policy.Action{ + {Id: "read-target", Name: "read", Namespace: namespace}, + } + + resolved, err := resolveExisting( + &DerivedTargets{ + Scopes: []Scope{ScopeActions}, + Actions: []*DerivedAction{ + { + Source: &policy.Action{Id: "legacy-read", Name: "read"}, + Targets: []*policy.Namespace{namespace}, + }, + }, + }, + existing, + ) + require.NoError(t, err) + + require.Len(t, resolved.Actions, 1) + require.Len(t, resolved.Actions[0].Results, 1) + assert.Equal(t, "read-target", resolved.Actions[0].Results[0].ExistingStandard.GetId()) + assert.False(t, resolved.Actions[0].Results[0].NeedsCreate) +} + +func TestResolveExistingFailsWhenActionCandidateIsNil(t *testing.T) { + t.Parallel() + + resolved, err := resolveExisting( + &DerivedTargets{ + Scopes: []Scope{ScopeActions}, + Actions: []*DerivedAction{nil}, + }, + nil, + ) + require.Error(t, err) + assert.Nil(t, resolved) + assert.EqualError(t, err, "could not determine target namespace: empty action candidate") +} + +func TestResolveExistingFailsWhenSubjectConditionSetCandidateIsNil(t *testing.T) { + t.Parallel() + + resolved, err := resolveExisting( + &DerivedTargets{ + Scopes: []Scope{ScopeSubjectConditionSets}, + SubjectConditionSets: []*DerivedSubjectConditionSet{nil}, + }, + nil, + ) + require.Error(t, err) + assert.Nil(t, resolved) + assert.EqualError(t, err, "could not determine target namespace: empty subject condition set candidate") +} + +func TestResolveExistingFailsWhenSubjectMappingCandidateIsNil(t *testing.T) { + t.Parallel() + + resolved, err := resolveExisting( + &DerivedTargets{ + Scopes: []Scope{ScopeSubjectMappings}, + SubjectMappings: []*DerivedSubjectMapping{nil}, + }, + nil, + ) + require.Error(t, err) + assert.Nil(t, resolved) + assert.EqualError(t, err, "could not determine target namespace: empty subject mapping candidate") +} + +func TestResolveExistingFailsWhenSubjectMappingActionDependencyMissing(t *testing.T) { + t.Parallel() + + namespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + + resolved, err := resolveExisting( + &DerivedTargets{ + Scopes: []Scope{ScopeActions, ScopeSubjectConditionSets, ScopeSubjectMappings}, + SubjectConditionSets: []*DerivedSubjectConditionSet{ + { + Source: &policy.SubjectConditionSet{Id: "scs-1"}, + Targets: []*policy.Namespace{namespace}, + }, + }, + SubjectMappings: []*DerivedSubjectMapping{ + { + Source: &policy.SubjectMapping{ + Id: "mapping-1", + Actions: []*policy.Action{ + {Id: "action-1", Name: "decrypt"}, + }, + SubjectConditionSet: &policy.SubjectConditionSet{Id: "scs-1"}, + }, + Target: namespace, + }, + }, + }, + nil, + ) + require.Error(t, err) + assert.Nil(t, resolved) + assert.EqualError( + t, + err, + `subject mapping "mapping-1" in namespace "ns-1": subject mapping dependency action "action-1" is not resolved in namespace "ns-1"`, + ) +} + +func TestResolveExistingKeepsRegisteredResourceConflictUnresolved(t *testing.T) { + t.Parallel() + + namespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + + resolved, err := resolveExisting( + &DerivedTargets{ + Scopes: []Scope{ScopeRegisteredResources}, + RegisteredResources: []*DerivedRegisteredResource{ + { + Source: &policy.RegisteredResource{Id: "resource-1", Name: "documents"}, + Target: namespace, + Unresolved: &Unresolved{ + Reason: UnresolvedReasonRegisteredResourceConflictingNamespaces, + Message: "could not determine target namespace: registered resource spans multiple target namespaces", + }, + }, + }, + }, + nil, + ) + require.NoError(t, err) + require.Len(t, resolved.RegisteredResources, 1) + require.NotNil(t, resolved.RegisteredResources[0].Unresolved) + assert.Equal(t, UnresolvedReasonRegisteredResourceConflictingNamespaces, resolved.RegisteredResources[0].Unresolved.Reason) + assert.Equal( + t, + "could not determine target namespace: registered resource spans multiple target namespaces", + resolved.RegisteredResources[0].Unresolved.Message, + ) + assert.False(t, resolved.RegisteredResources[0].NeedsCreate) +} diff --git a/otdfctl/migrations/namespacedpolicy/retrieve.go b/otdfctl/migrations/namespacedpolicy/retrieve.go new file mode 100644 index 0000000000..7df97295bd --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/retrieve.go @@ -0,0 +1,575 @@ +package namespacedpolicy + +import ( + "context" + "errors" + "fmt" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/actions" + "github.com/opentdf/platform/protocol/go/policy/namespaces" + "github.com/opentdf/platform/protocol/go/policy/obligations" + "github.com/opentdf/platform/protocol/go/policy/registeredresources" + "github.com/opentdf/platform/protocol/go/policy/subjectmapping" +) + +var ( + _ pagedResponse = (*actions.ListActionsResponse)(nil) + _ pagedResponse = (*subjectmapping.ListSubjectConditionSetsResponse)(nil) + _ pagedResponse = (*subjectmapping.ListSubjectMappingsResponse)(nil) + _ pagedResponse = (*registeredresources.ListRegisteredResourcesResponse)(nil) + _ pagedResponse = (*obligations.ListObligationTriggersResponse)(nil) + _ pagedResponse = (*namespaces.ListNamespacesResponse)(nil) +) + +type pagedResponse interface { + GetPagination() *policy.PageResponse +} + +type Retriever struct { + handler PolicyClient + pageSize int32 +} + +func newRetriever(handler PolicyClient, pageSize int32) *Retriever { + return &Retriever{ + handler: handler, + pageSize: pageSize, + } +} + +func (r *Retriever) retrieve(ctx context.Context, scopes scopeSet) (*Retrieved, error) { + retrieved := newRetrieved(scopes.ordered()) + + if scopes.requiresSubjectMappings() { + candidates, err := r.retrieveSubjectMappings(ctx) + if err != nil { + return nil, err + } + retrieved.Candidates.SubjectMappings = candidates + } + + if scopes.requiresSubjectConditionSets() { + candidates, err := r.retrieveSubjectConditionSets(ctx) + if err != nil { + return nil, err + } + retrieved.Candidates.SubjectConditionSets = candidates + } + + if scopes.requiresActions() { + candidates, err := r.retrieveActions(ctx) + if err != nil { + return nil, err + } + retrieved.Candidates.Actions = candidates + } + + if scopes.requiresRegisteredResources() { + candidates, err := r.retrieveRegisteredResources(ctx) + if err != nil { + return nil, err + } + retrieved.Candidates.RegisteredResources = candidates + } + + if scopes.requiresObligationTriggers() { + candidates, err := r.retrieveObligationTriggers(ctx) + if err != nil { + return nil, err + } + retrieved.Candidates.ObligationTriggers = candidates + } + + return retrieved, nil +} + +func (r *Retriever) listNamespaces(ctx context.Context) ([]*policy.Namespace, error) { + var ( + all []*policy.Namespace + offset int32 + ) + + for { + resp, err := r.handler.ListNamespaces(ctx, common.ActiveStateEnum_ACTIVE_STATE_ENUM_ACTIVE, r.pageSize, offset) + if err != nil { + return nil, fmt.Errorf("list namespaces: %w", err) + } + + items := resp.GetNamespaces() + if len(items) == 0 { + break + } + + all = append(all, items...) + + nextOffset, err := nextOffsetFromPage(resp) + if err != nil { + return nil, fmt.Errorf("list namespaces: %w", err) + } + if nextOffset <= 0 { + break + } + offset = nextOffset + } + + return all, nil +} + +func (r *Retriever) listExistingTargets(ctx context.Context, scopes scopeSet, derived *DerivedTargets) (*ExistingTargets, error) { + existing := newExistingTargets() + + if scopes.requiresActions() { + customActions, standardActions, err := r.listActionsForNamespaces(ctx, derivedActionNamespaces(derived)) + if err != nil { + return nil, err + } + existing.CustomActions = customActions + existing.StandardActions = standardActions + } + + if scopes.requiresSubjectConditionSets() { + subjectConditionSets, err := r.listSubjectConditionSetsForNamespaces(ctx, derivedSubjectConditionSetNamespaces(derived)) + if err != nil { + return nil, err + } + existing.SubjectConditionSets = subjectConditionSets + } + + if scopes.has(ScopeSubjectMappings) { + subjectMappings, err := r.listSubjectMappingsForNamespaces(ctx, derivedSubjectMappingNamespaces(derived)) + if err != nil { + return nil, err + } + existing.SubjectMappings = subjectMappings + } + + if scopes.has(ScopeRegisteredResources) { + registeredResources, err := r.listRegisteredResourcesForNamespaces(ctx, derivedRegisteredResourceNamespaces(derived)) + if err != nil { + return nil, err + } + existing.RegisteredResources = registeredResources + } + + if scopes.has(ScopeObligationTriggers) { + obligationTriggers, err := r.listObligationTriggersForNamespaces(ctx, derivedObligationTriggerNamespaces(derived)) + if err != nil { + return nil, err + } + existing.ObligationTriggers = obligationTriggers + } + + return existing, nil +} + +func (r *Retriever) retrieveSubjectMappings(ctx context.Context) ([]*policy.SubjectMapping, error) { + var ( + candidates []*policy.SubjectMapping + offset int32 + ) + + for { + resp, err := r.handler.ListSubjectMappings(ctx, r.pageSize, offset, "") + if err != nil { + return nil, fmt.Errorf("list subject mappings: %w", err) + } + + items := resp.GetSubjectMappings() + if len(items) == 0 { + break + } + + for _, mapping := range items { + if mapping.GetId() == "" || !isLegacyNamespace(mapping.GetNamespace()) || hasObject(candidates, mapping.GetId()) { + continue + } + candidates = append(candidates, mapping) + } + + nextOffset, err := nextOffsetFromPage(resp) + if err != nil { + return nil, fmt.Errorf("list subject mappings: %w", err) + } + if nextOffset <= 0 { + break + } + offset = nextOffset + } + + return candidates, nil +} + +func (r *Retriever) retrieveSubjectConditionSets(ctx context.Context) ([]*policy.SubjectConditionSet, error) { + var candidates []*policy.SubjectConditionSet + var offset int32 + + for { + resp, err := r.handler.ListSubjectConditionSets(ctx, r.pageSize, offset, "") + if err != nil { + return nil, fmt.Errorf("list subject condition sets: %w", err) + } + + items := resp.GetSubjectConditionSets() + if len(items) == 0 { + break + } + + for _, scs := range items { + if scs.GetId() == "" { + continue + } + if isLegacyNamespace(scs.GetNamespace()) && !hasObject(candidates, scs.GetId()) { + candidates = append(candidates, scs) + } + } + + nextOffset, err := nextOffsetFromPage(resp) + if err != nil { + return nil, fmt.Errorf("list subject condition sets: %w", err) + } + if nextOffset <= 0 { + break + } + offset = nextOffset + } + + return candidates, nil +} + +func (r *Retriever) retrieveRegisteredResources(ctx context.Context) ([]*policy.RegisteredResource, error) { + var ( + candidates []*policy.RegisteredResource + offset int32 + ) + + for { + resp, err := r.handler.ListRegisteredResources(ctx, r.pageSize, offset, "") + if err != nil { + return nil, fmt.Errorf("list registered resources: %w", err) + } + + items := resp.GetResources() + if len(items) == 0 { + break + } + + for _, resource := range items { + if resource.GetId() == "" || !isLegacyNamespace(resource.GetNamespace()) || hasObject(candidates, resource.GetId()) { + continue + } + candidates = append(candidates, resource) + } + + nextOffset, err := nextOffsetFromPage(resp) + if err != nil { + return nil, fmt.Errorf("list registered resources: %w", err) + } + if nextOffset <= 0 { + break + } + offset = nextOffset + } + + return candidates, nil +} + +func (r *Retriever) retrieveActions(ctx context.Context) ([]*policy.Action, error) { + var candidates []*policy.Action + var offset int32 + + for { + resp, err := r.handler.ListActions(ctx, r.pageSize, offset, "") + if err != nil { + return nil, fmt.Errorf("list actions: %w", err) + } + + if len(resp.GetActionsStandard()) == 0 && len(resp.GetActionsCustom()) == 0 { + break + } + + for _, action := range resp.GetActionsStandard() { + if action.GetId() == "" { + continue + } + if isLegacyNamespace(action.GetNamespace()) { + if !hasObject(candidates, action.GetId()) { + candidates = append(candidates, action) + } + continue + } + } + + for _, action := range resp.GetActionsCustom() { + if action.GetId() == "" { + continue + } + if isLegacyNamespace(action.GetNamespace()) { + if !hasObject(candidates, action.GetId()) { + candidates = append(candidates, action) + } + continue + } + } + + nextOffset, err := nextOffsetFromPage(resp) + if err != nil { + return nil, fmt.Errorf("list actions: %w", err) + } + if nextOffset <= 0 { + break + } + offset = nextOffset + } + + return candidates, nil +} + +func (r *Retriever) retrieveObligationTriggers(ctx context.Context) ([]*policy.ObligationTrigger, error) { + var ( + candidates []*policy.ObligationTrigger + offset int32 + ) + + for { + resp, err := r.handler.ListObligationTriggers(ctx, "", r.pageSize, offset) + if err != nil { + return nil, fmt.Errorf("list obligation triggers: %w", err) + } + + items := resp.GetTriggers() + if len(items) == 0 { + break + } + + for _, trigger := range items { + if trigger.GetId() == "" || trigger.GetAction() == nil || !isLegacyNamespace(trigger.GetAction().GetNamespace()) || hasObject(candidates, trigger.GetId()) { + continue + } + candidates = append(candidates, trigger) + } + + nextOffset, err := nextOffsetFromPage(resp) + if err != nil { + return nil, fmt.Errorf("list obligation triggers: %w", err) + } + if nextOffset <= 0 { + break + } + offset = nextOffset + } + + return candidates, nil +} + +func (r *Retriever) listActionsForNamespaces(ctx context.Context, namespaces []*policy.Namespace) (map[string][]*policy.Action, map[string][]*policy.Action, error) { + customByNamespace := make(map[string][]*policy.Action) + standardByNamespace := make(map[string][]*policy.Action) + + for _, namespace := range dedupeTargetNamespaces(namespaces) { + var offset int32 + for { + resp, err := r.handler.ListActions(ctx, r.pageSize, offset, namespace.GetId()) + if err != nil { + return nil, nil, fmt.Errorf("list actions for namespace %s: %w", namespace.GetId(), err) + } + + for _, action := range resp.GetActionsCustom() { + if action.GetId() == "" || hasObject(customByNamespace[namespace.GetId()], action.GetId()) { + continue + } + customByNamespace[namespace.GetId()] = append(customByNamespace[namespace.GetId()], action) + } + for _, action := range resp.GetActionsStandard() { + if action.GetId() == "" || hasObject(standardByNamespace[namespace.GetId()], action.GetId()) { + continue + } + standardByNamespace[namespace.GetId()] = append(standardByNamespace[namespace.GetId()], action) + } + + nextOffset, err := nextOffsetFromPage(resp) + if err != nil { + return nil, nil, fmt.Errorf("list actions for namespace %s: %w", namespace.GetId(), err) + } + if nextOffset <= 0 { + break + } + offset = nextOffset + } + } + + return customByNamespace, standardByNamespace, nil +} + +func (r *Retriever) listSubjectConditionSetsForNamespaces(ctx context.Context, namespaces []*policy.Namespace) (map[string][]*policy.SubjectConditionSet, error) { + byNamespace := make(map[string][]*policy.SubjectConditionSet) + + for _, namespace := range dedupeTargetNamespaces(namespaces) { + var offset int32 + for { + resp, err := r.handler.ListSubjectConditionSets(ctx, r.pageSize, offset, namespace.GetId()) + if err != nil { + return nil, fmt.Errorf("list subject condition sets for namespace %s: %w", namespace.GetId(), err) + } + + for _, scs := range resp.GetSubjectConditionSets() { + if scs.GetId() == "" || hasObject(byNamespace[namespace.GetId()], scs.GetId()) { + continue + } + byNamespace[namespace.GetId()] = append(byNamespace[namespace.GetId()], scs) + } + + nextOffset, err := nextOffsetFromPage(resp) + if err != nil { + return nil, fmt.Errorf("list subject condition sets for namespace %s: %w", namespace.GetId(), err) + } + if nextOffset <= 0 { + break + } + offset = nextOffset + } + } + + return byNamespace, nil +} + +func (r *Retriever) listSubjectMappingsForNamespaces(ctx context.Context, namespaces []*policy.Namespace) (map[string][]*policy.SubjectMapping, error) { + byNamespace := make(map[string][]*policy.SubjectMapping) + + for _, namespace := range dedupeTargetNamespaces(namespaces) { + var offset int32 + for { + resp, err := r.handler.ListSubjectMappings(ctx, r.pageSize, offset, namespace.GetId()) + if err != nil { + return nil, fmt.Errorf("list subject mappings for namespace %s: %w", namespace.GetId(), err) + } + + for _, mapping := range resp.GetSubjectMappings() { + if mapping.GetId() == "" || hasObject(byNamespace[namespace.GetId()], mapping.GetId()) { + continue + } + byNamespace[namespace.GetId()] = append(byNamespace[namespace.GetId()], mapping) + } + + nextOffset, err := nextOffsetFromPage(resp) + if err != nil { + return nil, fmt.Errorf("list subject mappings for namespace %s: %w", namespace.GetId(), err) + } + if nextOffset <= 0 { + break + } + offset = nextOffset + } + } + + return byNamespace, nil +} + +func (r *Retriever) listRegisteredResourcesForNamespaces(ctx context.Context, namespaces []*policy.Namespace) (map[string][]*policy.RegisteredResource, error) { + byNamespace := make(map[string][]*policy.RegisteredResource) + + for _, namespace := range dedupeTargetNamespaces(namespaces) { + var offset int32 + for { + resp, err := r.handler.ListRegisteredResources(ctx, r.pageSize, offset, namespace.GetId()) + if err != nil { + return nil, fmt.Errorf("list registered resources for namespace %s: %w", namespace.GetId(), err) + } + + for _, resource := range resp.GetResources() { + if resource.GetId() == "" || hasObject(byNamespace[namespace.GetId()], resource.GetId()) { + continue + } + byNamespace[namespace.GetId()] = append(byNamespace[namespace.GetId()], resource) + } + + nextOffset, err := nextOffsetFromPage(resp) + if err != nil { + return nil, fmt.Errorf("list registered resources for namespace %s: %w", namespace.GetId(), err) + } + if nextOffset <= 0 { + break + } + offset = nextOffset + } + } + + return byNamespace, nil +} + +func (r *Retriever) listObligationTriggersForNamespaces(ctx context.Context, namespaces []*policy.Namespace) (map[string][]*policy.ObligationTrigger, error) { + byNamespace := make(map[string][]*policy.ObligationTrigger) + + for _, namespace := range dedupeTargetNamespaces(namespaces) { + var offset int32 + for { + resp, err := r.handler.ListObligationTriggers(ctx, namespace.GetId(), r.pageSize, offset) + if err != nil { + return nil, fmt.Errorf("list obligation triggers for namespace %s: %w", namespace.GetId(), err) + } + + for _, trigger := range resp.GetTriggers() { + if trigger.GetId() == "" || hasObject(byNamespace[namespace.GetId()], trigger.GetId()) { + continue + } + byNamespace[namespace.GetId()] = append(byNamespace[namespace.GetId()], trigger) + } + + nextOffset, err := nextOffsetFromPage(resp) + if err != nil { + return nil, fmt.Errorf("list obligation triggers for namespace %s: %w", namespace.GetId(), err) + } + if nextOffset <= 0 { + break + } + offset = nextOffset + } + } + + return byNamespace, nil +} + +func dedupeTargetNamespaces(namespaces []*policy.Namespace) []*policy.Namespace { + deduped := make([]*policy.Namespace, 0, len(namespaces)) + seen := make(map[string]struct{}, len(namespaces)) + + for _, namespace := range namespaces { + if namespace == nil || namespace.GetId() == "" { + continue + } + if _, ok := seen[namespace.GetId()]; ok { + continue + } + seen[namespace.GetId()] = struct{}{} + deduped = append(deduped, namespace) + } + + return deduped +} + +func isLegacyNamespace(namespace *policy.Namespace) bool { + return namespace == nil || (namespace.GetId() == "" && namespace.GetFqn() == "") +} + +func namespaceRefKey(namespace *policy.Namespace) string { + if namespace == nil { + return "" + } + if id := namespace.GetId(); id != "" { + return "id:" + id + } + if fqn := namespace.GetFqn(); fqn != "" { + return "fqn:" + fqn + } + return "" +} + +func nextOffsetFromPage(resp pagedResponse) (int32, error) { + page := resp.GetPagination() + if page == nil { + return 0, errors.New("missing pagination in response") + } + + return page.GetNextOffset(), nil +} diff --git a/otdfctl/migrations/namespacedpolicy/retrieve_test.go b/otdfctl/migrations/namespacedpolicy/retrieve_test.go new file mode 100644 index 0000000000..820651b9d6 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/retrieve_test.go @@ -0,0 +1,253 @@ +package namespacedpolicy + +import ( + "context" + "testing" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/actions" + "github.com/opentdf/platform/protocol/go/policy/namespaces" + "github.com/opentdf/platform/protocol/go/policy/obligations" + "github.com/opentdf/platform/protocol/go/policy/registeredresources" + "github.com/opentdf/platform/protocol/go/policy/subjectmapping" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRetrieverRetrieveActionsFiltersLegacyAndDedupes(t *testing.T) { + t.Parallel() + + handler := &plannerTestHandler{ + actionsByNamespace: map[string]*actions.ListActionsResponse{ + "": { + ActionsStandard: []*policy.Action{ + {Id: "action-dup", Name: "read"}, + { + Id: "action-namespaced", + Name: "write", + Namespace: &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"}, + }, + }, + ActionsCustom: []*policy.Action{ + {Id: "action-dup", Name: "read"}, + {Id: "action-custom", Name: "decrypt"}, + }, + Pagination: emptyPageResponse(), + }, + }, + } + + actions, err := newRetriever(handler, 25).retrieveActions(t.Context()) + require.NoError(t, err) + + assert.ElementsMatch(t, []string{"action-dup", "action-custom"}, policyObjectIDs(actions)) + assert.Equal(t, []string{""}, handler.actionCalls) +} + +func TestRetrieverListRegisteredResourcesForNamespacesDedupesNamespacesAndUsesInlineValues(t *testing.T) { + t.Parallel() + + namespace := &policy.Namespace{ + Id: "ns-1", + Fqn: "https://example.com", + } + value := testRegisteredResourceValue( + "prod", + testActionAttributeValue( + "action-1", + "decrypt", + testAttributeValue("https://example.com/attr/classification/value/secret", nil), + ), + ) + resource := &policy.RegisteredResource{ + Id: "resource-1", + Name: "documents", + Values: []*policy.RegisteredResourceValue{value}, + } + handler := &plannerTestHandler{ + registeredResourcesByNamespace: map[string]*registeredresources.ListRegisteredResourcesResponse{ + namespace.GetId(): { + Resources: []*policy.RegisteredResource{resource}, + Pagination: emptyPageResponse(), + }, + }, + } + + resources, err := newRetriever(handler, 25).listRegisteredResourcesForNamespaces( + context.Background(), + []*policy.Namespace{namespace, namespace}, + ) + require.NoError(t, err) + + require.Contains(t, resources, namespace.GetId()) + require.Len(t, resources[namespace.GetId()], 1) + assert.Equal(t, resource.GetId(), resources[namespace.GetId()][0].GetId()) + require.Len(t, resources[namespace.GetId()][0].GetValues(), 1) + assert.Equal(t, "prod", resources[namespace.GetId()][0].GetValues()[0].GetValue()) + assert.Equal(t, []string{namespace.GetId()}, handler.registeredResourceCalls) +} + +func TestRetrieverRetrieveSubjectMappingsDedupesAcrossPages(t *testing.T) { + t.Parallel() + + handler := &pagedRetrieveTestHandler{ + subjectMappingPages: map[int32]*subjectmapping.ListSubjectMappingsResponse{ + 0: { + SubjectMappings: []*policy.SubjectMapping{ + {Id: "mapping-dup"}, + { + Id: "mapping-namespaced", + Namespace: &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"}, + }, + }, + Pagination: pageResponse(1), + }, + 1: { + SubjectMappings: []*policy.SubjectMapping{ + {Id: "mapping-dup"}, + {Id: "mapping-new"}, + {Id: ""}, + }, + Pagination: emptyPageResponse(), + }, + }, + } + + mappings, err := newRetriever(handler, 25).retrieveSubjectMappings(t.Context()) + require.NoError(t, err) + + assert.Equal(t, []string{"mapping-dup", "mapping-new"}, policyObjectIDs(mappings)) +} + +func TestRetrieverRetrieveRegisteredResourcesDedupesAcrossPages(t *testing.T) { + t.Parallel() + + handler := &pagedRetrieveTestHandler{ + registeredResourcePages: map[int32]*registeredresources.ListRegisteredResourcesResponse{ + 0: { + Resources: []*policy.RegisteredResource{ + {Id: "resource-dup", Name: "documents"}, + { + Id: "resource-namespaced", + Name: "contracts", + Namespace: &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"}, + }, + }, + Pagination: pageResponse(1), + }, + 1: { + Resources: []*policy.RegisteredResource{ + {Id: "resource-dup", Name: "documents"}, + {Id: "resource-new", Name: "reports"}, + {Id: "", Name: "missing-id"}, + }, + Pagination: emptyPageResponse(), + }, + }, + } + + resources, err := newRetriever(handler, 25).retrieveRegisteredResources(t.Context()) + require.NoError(t, err) + + assert.Equal(t, []string{"resource-dup", "resource-new"}, policyObjectIDs(resources)) +} + +func TestRetrieverRetrieveObligationTriggersDedupesAcrossPages(t *testing.T) { + t.Parallel() + + handler := &pagedRetrieveTestHandler{ + obligationTriggerPages: map[int32]*obligations.ListObligationTriggersResponse{ + 0: { + Triggers: []*policy.ObligationTrigger{ + { + Id: "trigger-dup", + Action: &policy.Action{Id: "action-1", Name: "decrypt"}, + }, + { + Id: "trigger-namespaced", + Action: &policy.Action{Id: "action-2", Name: "decrypt", Namespace: &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"}}, + }, + }, + Pagination: pageResponse(1), + }, + 1: { + Triggers: []*policy.ObligationTrigger{ + { + Id: "trigger-dup", + Action: &policy.Action{Id: "action-1", Name: "decrypt"}, + }, + { + Id: "trigger-new", + Action: &policy.Action{Id: "action-3", Name: "encrypt"}, + }, + { + Id: "", + Action: &policy.Action{Id: "action-4", Name: "read"}, + }, + }, + Pagination: emptyPageResponse(), + }, + }, + } + + triggers, err := newRetriever(handler, 25).retrieveObligationTriggers(t.Context()) + require.NoError(t, err) + + assert.Equal(t, []string{"trigger-dup", "trigger-new"}, policyObjectIDs(triggers)) +} + +func policyObjectIDs[T interface{ GetId() string }](items []T) []string { + ids := make([]string, 0, len(items)) + for _, item := range items { + if item.GetId() == "" { + continue + } + ids = append(ids, item.GetId()) + } + + return ids +} + +type pagedRetrieveTestHandler struct { + subjectMappingPages map[int32]*subjectmapping.ListSubjectMappingsResponse + registeredResourcePages map[int32]*registeredresources.ListRegisteredResourcesResponse + obligationTriggerPages map[int32]*obligations.ListObligationTriggersResponse +} + +func (h *pagedRetrieveTestHandler) ListActions(_ context.Context, limit, offset int32, namespace string) (*actions.ListActionsResponse, error) { + return &actions.ListActionsResponse{Pagination: emptyPageResponse()}, nil +} + +func (h *pagedRetrieveTestHandler) ListSubjectConditionSets(_ context.Context, limit, offset int32, namespace string) (*subjectmapping.ListSubjectConditionSetsResponse, error) { + return &subjectmapping.ListSubjectConditionSetsResponse{Pagination: emptyPageResponse()}, nil +} + +func (h *pagedRetrieveTestHandler) ListSubjectMappings(_ context.Context, limit, offset int32, namespace string) (*subjectmapping.ListSubjectMappingsResponse, error) { + if resp, ok := h.subjectMappingPages[offset]; ok { + return resp, nil + } + return &subjectmapping.ListSubjectMappingsResponse{Pagination: emptyPageResponse()}, nil +} + +func (h *pagedRetrieveTestHandler) ListRegisteredResources(_ context.Context, limit, offset int32, namespace string) (*registeredresources.ListRegisteredResourcesResponse, error) { + if resp, ok := h.registeredResourcePages[offset]; ok { + return resp, nil + } + return ®isteredresources.ListRegisteredResourcesResponse{Pagination: emptyPageResponse()}, nil +} + +func (h *pagedRetrieveTestHandler) ListObligationTriggers(_ context.Context, namespace string, limit, offset int32) (*obligations.ListObligationTriggersResponse, error) { + if resp, ok := h.obligationTriggerPages[offset]; ok { + return resp, nil + } + return &obligations.ListObligationTriggersResponse{Pagination: emptyPageResponse()}, nil +} + +func (h *pagedRetrieveTestHandler) ListNamespaces(_ context.Context, state common.ActiveStateEnum, limit, offset int32) (*namespaces.ListNamespacesResponse, error) { + return &namespaces.ListNamespacesResponse{Pagination: emptyPageResponse()}, nil +} + +func pageResponse(nextOffset int32) *policy.PageResponse { + return &policy.PageResponse{NextOffset: nextOffset} +} diff --git a/otdfctl/migrations/namespacedpolicy/scopes.go b/otdfctl/migrations/namespacedpolicy/scopes.go new file mode 100644 index 0000000000..198aa14c1f --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/scopes.go @@ -0,0 +1,143 @@ +package namespacedpolicy + +import ( + "errors" + "fmt" + "strings" +) + +var ( + ErrEmptyPlannerScope = errors.New("at least one migration scope is required") + ErrInvalidScope = errors.New("invalid migration scope") +) + +type Scope string + +const ( + ScopeActions Scope = "actions" + ScopeSubjectConditionSets Scope = "subject-condition-sets" + ScopeSubjectMappings Scope = "subject-mappings" + ScopeRegisteredResources Scope = "registered-resources" + ScopeObligationTriggers Scope = "obligation-triggers" +) + +var supportedScopes = []Scope{ + ScopeActions, + ScopeSubjectConditionSets, + ScopeSubjectMappings, + ScopeRegisteredResources, + ScopeObligationTriggers, +} + +func ParseScopes(csv string) ([]Scope, error) { + if strings.TrimSpace(csv) == "" { + return nil, ErrEmptyPlannerScope + } + + scopes, err := normalizeScopes(splitScopes(csv)) + if err != nil { + return nil, err + } + + return scopes.ordered(), nil +} + +func splitScopes(csv string) []Scope { + rawScopes := strings.Split(csv, ",") + scopes := make([]Scope, 0, len(rawScopes)) + for _, raw := range rawScopes { + scopes = append(scopes, Scope(strings.TrimSpace(raw))) + } + + return scopes +} + +func normalizeScopes(scopes []Scope) (scopeSet, error) { + if len(scopes) == 0 { + return nil, ErrEmptyPlannerScope + } + + requested := make(scopeSet, len(supportedScopes)) + for _, scope := range scopes { + if scope == "" { + return nil, ErrEmptyPlannerScope + } + if !isSupportedScope(scope) { + return nil, fmt.Errorf("%w: %s", ErrInvalidScope, scope) + } + requested[scope] = struct{}{} + } + + return requested, nil +} + +func expandScopes(scopes scopeSet) scopeSet { + if len(scopes) == 0 { + return scopes + } + + expanded := make(scopeSet, len(supportedScopes)) + for scope := range scopes { + expanded[scope] = struct{}{} + } + + if expanded.has(ScopeSubjectMappings) { + expanded[ScopeActions] = struct{}{} + expanded[ScopeSubjectConditionSets] = struct{}{} + } + if expanded.has(ScopeRegisteredResources) { + expanded[ScopeActions] = struct{}{} + } + if expanded.has(ScopeObligationTriggers) { + expanded[ScopeActions] = struct{}{} + } + + return expanded +} + +type scopeSet map[Scope]struct{} + +func (s scopeSet) ordered() []Scope { + ordered := make([]Scope, 0, len(s)) + for _, scope := range supportedScopes { + if s.has(scope) { + ordered = append(ordered, scope) + } + } + + return ordered +} + +func (s scopeSet) has(scope Scope) bool { + _, ok := s[scope] + return ok +} + +func (s scopeSet) requiresActions() bool { + return s.has(ScopeActions) || s.has(ScopeSubjectMappings) || s.has(ScopeRegisteredResources) || s.has(ScopeObligationTriggers) +} + +func (s scopeSet) requiresSubjectConditionSets() bool { + return s.has(ScopeSubjectConditionSets) || s.has(ScopeSubjectMappings) +} + +func (s scopeSet) requiresSubjectMappings() bool { + return s.has(ScopeActions) || s.has(ScopeSubjectConditionSets) || s.has(ScopeSubjectMappings) +} + +func (s scopeSet) requiresRegisteredResources() bool { + return s.has(ScopeActions) || s.has(ScopeRegisteredResources) +} + +func (s scopeSet) requiresObligationTriggers() bool { + return s.has(ScopeActions) || s.has(ScopeObligationTriggers) +} + +func isSupportedScope(scope Scope) bool { + for _, supported := range supportedScopes { + if scope == supported { + return true + } + } + return false +} diff --git a/otdfctl/migrations/namespacedpolicy/scopes_test.go b/otdfctl/migrations/namespacedpolicy/scopes_test.go new file mode 100644 index 0000000000..aae0163c15 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/scopes_test.go @@ -0,0 +1,71 @@ +package namespacedpolicy + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseScopesNormalizesAndOrders(t *testing.T) { + t.Parallel() + + scopes, err := ParseScopes(" registered-resources, actions, subject-mappings, actions ") + require.NoError(t, err) + + assert.Equal(t, []Scope{ + ScopeActions, + ScopeSubjectMappings, + ScopeRegisteredResources, + }, scopes) +} + +func TestParseScopesRejectsInvalidInput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + err error + }{ + { + name: "empty csv", + input: " ", + err: ErrEmptyPlannerScope, + }, + { + name: "empty entry", + input: "actions,", + err: ErrEmptyPlannerScope, + }, + { + name: "invalid scope", + input: "actions,widgets", + err: ErrInvalidScope, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := ParseScopes(tt.input) + require.ErrorIs(t, err, tt.err) + }) + } +} + +func TestExpandScopesAddsRequiredDependencies(t *testing.T) { + t.Parallel() + + requested, err := normalizeScopes([]Scope{ScopeSubjectMappings, ScopeObligationTriggers}) + require.NoError(t, err) + + assert.Equal(t, []Scope{ + ScopeActions, + ScopeSubjectConditionSets, + ScopeSubjectMappings, + ScopeObligationTriggers, + }, expandScopes(requested).ordered()) +} diff --git a/otdfctl/migrations/namespacedpolicy/subject_condition_sets_execute.go b/otdfctl/migrations/namespacedpolicy/subject_condition_sets_execute.go new file mode 100644 index 0000000000..28b39f7705 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/subject_condition_sets_execute.go @@ -0,0 +1,134 @@ +package namespacedpolicy + +import ( + "context" + "fmt" + + "github.com/opentdf/platform/protocol/go/policy" +) + +func (e *Executor) rememberSubjectConditionSetTarget(sourceID string, target *SubjectConditionSetTargetPlan) { + if e == nil || sourceID == "" || target == nil { + return + } + + namespaceKey := namespaceRefKey(target.Namespace) + if namespaceKey == "" { + return + } + + if e.subjectConditionSets == nil { + e.subjectConditionSets = make(map[string]map[string]*SubjectConditionSetTargetPlan) + } + if e.subjectConditionSets[sourceID] == nil { + e.subjectConditionSets[sourceID] = make(map[string]*SubjectConditionSetTargetPlan) + } + + e.subjectConditionSets[sourceID][namespaceKey] = target +} + +func (e *Executor) cachedScsTargetID(sourceID string, namespace *policy.Namespace) string { + if e == nil || sourceID == "" { + return "" + } + + namespaceKey := namespaceRefKey(namespace) + if namespaceKey == "" { + return "" + } + + targets := e.subjectConditionSets[sourceID] + if targets == nil { + return "" + } + + target := targets[namespaceKey] + if target == nil { + return "" + } + + return target.TargetID() +} + +func (e *Executor) executeSubjectConditionSets(ctx context.Context, plans []*SubjectConditionSetPlan) error { + if len(plans) == 0 { + return nil + } + + for _, scsPlan := range plans { + if scsPlan == nil || scsPlan.Source == nil { + continue + } + + for _, target := range scsPlan.Targets { + if target == nil { + continue + } + + if err := e.executeSubjectConditionSetTarget(ctx, scsPlan, target); err != nil { + return err + } + } + } + + return nil +} + +func (e *Executor) executeSubjectConditionSetTarget(ctx context.Context, scsPlan *SubjectConditionSetPlan, target *SubjectConditionSetTargetPlan) error { + //nolint:exhaustive // SCS execution only handles create and already-migrated explicitly; all other statuses are unsupported. + switch target.Status { + case TargetStatusAlreadyMigrated: + if target.TargetID() == "" { + return fmt.Errorf("%w: subject condition set %q target %q", ErrMissingMigratedTarget, scsPlan.Source.GetId(), namespaceLabel(target.Namespace)) + } + e.rememberSubjectConditionSetTarget(scsPlan.Source.GetId(), target) + return nil + case TargetStatusCreate: + return e.createSubjectConditionSetTarget(ctx, scsPlan, target) + case TargetStatusUnresolved: + return fmt.Errorf("%w: subject condition set %q target %q is unresolved: %s", ErrPlanNotExecutable, scsPlan.Source.GetId(), namespaceLabel(target.Namespace), target.Reason) + default: + return fmt.Errorf("%w: subject condition set %q target %q has unsupported status %q", ErrUnsupportedStatus, scsPlan.Source.GetId(), namespaceLabel(target.Namespace), target.Status) + } +} + +func (e *Executor) createSubjectConditionSetTarget(ctx context.Context, scsPlan *SubjectConditionSetPlan, target *SubjectConditionSetTargetPlan) error { + namespace := namespaceIdentifier(target.Namespace) + if namespace == "" { + return fmt.Errorf("%w: subject condition set %q", ErrTargetNamespaceRequired, scsPlan.Source.GetId()) + } + + created, err := e.handler.CreateSubjectConditionSet( + ctx, + scsPlan.Source.GetSubjectSets(), + metadataForCreate( + scsPlan.Source.GetId(), + metadataLabels(scsPlan.Source.GetMetadata()), + e.runID, + ), + namespace, + ) + if err != nil { + target.Execution = &ExecutionResult{ + RunID: e.runID, + Failure: err.Error(), + } + return fmt.Errorf("create subject condition set %q in namespace %q: %w", scsPlan.Source.GetId(), namespaceLabel(target.Namespace), err) + } + if created.GetId() == "" { + target.Execution = &ExecutionResult{ + RunID: e.runID, + Failure: ErrMissingCreatedTargetID.Error(), + } + return fmt.Errorf("%w: subject condition set %q target %q", ErrMissingCreatedTargetID, scsPlan.Source.GetId(), namespaceLabel(target.Namespace)) + } + + target.Execution = &ExecutionResult{ + RunID: e.runID, + Applied: true, + CreatedTargetID: created.GetId(), + } + e.rememberSubjectConditionSetTarget(scsPlan.Source.GetId(), target) + + return nil +} diff --git a/otdfctl/migrations/namespacedpolicy/subject_condition_sets_execute_test.go b/otdfctl/migrations/namespacedpolicy/subject_condition_sets_execute_test.go new file mode 100644 index 0000000000..74c292d24a --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/subject_condition_sets_execute_test.go @@ -0,0 +1,322 @@ +package namespacedpolicy + +import ( + "errors" + "testing" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecuteSubjectConditionSets(t *testing.T) { + t.Parallel() + + namespace1 := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + namespace2 := &policy.Namespace{Id: "ns-2", Fqn: "https://example.net"} + errBoom := errors.New("boom") + subjectSets := []*policy.SubjectSet{ + { + ConditionGroups: []*policy.ConditionGroup{ + { + BooleanOperator: policy.ConditionBooleanTypeEnum_CONDITION_BOOLEAN_TYPE_ENUM_AND, + Conditions: []*policy.Condition{ + { + SubjectExternalSelectorValue: "https://example.com/selector/role", + Operator: policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN, + SubjectExternalValues: []string{"admin"}, + }, + }, + }, + }, + }, + } + + tests := []struct { + name string + plan *Plan + handler *mockExecutorHandler + runID string + wantErr *expectedError + assert func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, plan *Plan) + }{ + { + name: "handles created and already migrated subject condition set targets", + plan: &Plan{ + Scopes: []Scope{ScopeSubjectConditionSets}, + SubjectConditionSets: []*SubjectConditionSetPlan{ + { + Source: &policy.SubjectConditionSet{ + Id: "scs-1", + SubjectSets: subjectSets, + Metadata: &common.Metadata{ + Labels: map[string]string{ + "owner": "policy-team", + "env": "dev", + }, + }, + }, + Targets: []*SubjectConditionSetTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + }, + { + Namespace: namespace2, + Status: TargetStatusAlreadyMigrated, + Existing: &policy.SubjectConditionSet{Id: "migrated-scs-1"}, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{ + subjectConditionSetResult: map[string]map[string]*policy.SubjectConditionSet{ + "scs-1": { + "ns-1": {Id: "created-scs-1"}, + }, + }, + }, + runID: "run-456", + assert: func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.NoError(t, err) + require.Contains(t, handler.createdSubjectConditions, "scs-1") + require.Contains(t, handler.createdSubjectConditions["scs-1"], "ns-1") + assert.Len(t, handler.createdSubjectConditions["scs-1"], 1) + assert.Equal(t, subjectSets, handler.createdSubjectConditions["scs-1"]["ns-1"].SubjectSets) + assert.Equal(t, "ns-1", handler.createdSubjectConditions["scs-1"]["ns-1"].Namespace) + assert.Equal(t, map[string]string{ + "owner": "policy-team", + "env": "dev", + migrationLabelMigratedFrom: "scs-1", + migrationLabelRun: "run-456", + }, handler.createdSubjectConditions["scs-1"]["ns-1"].Metadata.GetLabels()) + + createdTarget := plan.SubjectConditionSets[0].Targets[0] + assert.Equal(t, TargetStatusCreate, createdTarget.Status) + assert.Nil(t, createdTarget.Existing) + require.NotNil(t, createdTarget.Execution) + assert.True(t, createdTarget.Execution.Applied) + assert.Equal(t, "created-scs-1", createdTarget.Execution.CreatedTargetID) + assert.Equal(t, "run-456", createdTarget.Execution.RunID) + assert.Equal(t, "created-scs-1", createdTarget.TargetID()) + + migratedTarget := plan.SubjectConditionSets[0].Targets[1] + assert.Equal(t, "migrated-scs-1", migratedTarget.TargetID()) + assert.Nil(t, migratedTarget.Execution) + assert.Equal(t, "created-scs-1", executor.cachedScsTargetID("scs-1", namespace1)) + assert.Equal(t, "migrated-scs-1", executor.cachedScsTargetID("scs-1", namespace2)) + assert.Empty(t, executor.cachedScsTargetID("scs-2", namespace1)) + }, + }, + { + name: "returns not executable for unresolved target status", + plan: &Plan{ + Scopes: []Scope{ScopeSubjectConditionSets}, + SubjectConditionSets: []*SubjectConditionSetPlan{ + { + Source: &policy.SubjectConditionSet{Id: "scs-1"}, + Targets: []*SubjectConditionSetTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusUnresolved, + Reason: "missing target namespace mapping", + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError( + ErrPlanNotExecutable, + `subject condition set %q target %q is unresolved: %s`, + "scs-1", + namespace1.GetFqn(), + "missing target namespace mapping", + ), + assert: func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.createdSubjectConditions) + }, + }, + { + name: "returns error for missing already migrated target id", + plan: &Plan{ + Scopes: []Scope{ScopeSubjectConditionSets}, + SubjectConditionSets: []*SubjectConditionSetPlan{ + { + Source: &policy.SubjectConditionSet{Id: "scs-1"}, + Targets: []*SubjectConditionSetTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError(ErrMissingMigratedTarget, `subject condition set %q target %q`, "scs-1", namespace1.GetFqn()), + assert: func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.createdSubjectConditions) + }, + }, + { + name: "returns error for missing target namespace", + plan: &Plan{ + Scopes: []Scope{ScopeSubjectConditionSets}, + SubjectConditionSets: []*SubjectConditionSetPlan{ + { + Source: &policy.SubjectConditionSet{Id: "scs-1", SubjectSets: subjectSets}, + Targets: []*SubjectConditionSetTargetPlan{ + { + Status: TargetStatusCreate, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError(ErrTargetNamespaceRequired, `subject condition set %q`, "scs-1"), + assert: func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.createdSubjectConditions) + }, + }, + { + name: "returns error for missing created target id", + plan: &Plan{ + Scopes: []Scope{ScopeSubjectConditionSets}, + SubjectConditionSets: []*SubjectConditionSetPlan{ + { + Source: &policy.SubjectConditionSet{Id: "scs-1", SubjectSets: subjectSets}, + Targets: []*SubjectConditionSetTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{ + subjectConditionSetResult: map[string]map[string]*policy.SubjectConditionSet{ + "scs-1": { + "ns-1": {}, + }, + }, + }, + wantErr: wantError(ErrMissingCreatedTargetID, `subject condition set %q target %q`, "scs-1", namespace1.GetFqn()), + assert: func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.Error(t, err) + require.Contains(t, handler.createdSubjectConditions, "scs-1") + require.NotNil(t, plan.SubjectConditionSets[0].Targets[0].Execution) + assert.Equal(t, ErrMissingCreatedTargetID.Error(), plan.SubjectConditionSets[0].Targets[0].Execution.Failure) + assert.Empty(t, executor.cachedScsTargetID("scs-1", namespace1)) + }, + }, + { + name: "returns error for unsupported target status", + plan: &Plan{ + Scopes: []Scope{ScopeSubjectConditionSets}, + SubjectConditionSets: []*SubjectConditionSetPlan{ + { + Source: &policy.SubjectConditionSet{Id: "scs-1"}, + Targets: []*SubjectConditionSetTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatus("bogus"), + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError( + ErrUnsupportedStatus, + `subject condition set %q target %q has unsupported status %q`, + "scs-1", + namespace1.GetFqn(), + TargetStatus("bogus"), + ), + assert: func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.createdSubjectConditions) + }, + }, + { + name: "records create failures on the target", + plan: &Plan{ + Scopes: []Scope{ScopeSubjectConditionSets}, + SubjectConditionSets: []*SubjectConditionSetPlan{ + { + Source: &policy.SubjectConditionSet{Id: "scs-1", SubjectSets: subjectSets}, + Targets: []*SubjectConditionSetTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{ + subjectConditionSetErrs: map[string]map[string]error{ + "scs-1": { + "ns-1": errBoom, + }, + }, + }, + wantErr: &expectedError{ + is: errBoom, + message: `create subject condition set "scs-1" in namespace "https://example.com": boom`, + }, + assert: func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.Error(t, err) + require.NotNil(t, plan.SubjectConditionSets[0].Targets[0].Execution) + assert.Equal(t, "boom", plan.SubjectConditionSets[0].Targets[0].Execution.Failure) + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + executor, err := NewExecutor(tt.handler) + require.NoError(t, err) + if tt.runID != "" { + executor.runID = tt.runID + } + + err = executor.Execute(t.Context(), tt.plan) + switch { + case tt.wantErr != nil: + require.Error(t, err) + require.ErrorIs(t, err, tt.wantErr.is) + require.EqualError(t, err, tt.wantErr.message) + default: + require.NoError(t, err) + } + + tt.assert(t, err, executor, tt.handler, tt.plan) + }) + } +} diff --git a/otdfctl/migrations/namespacedpolicy/subject_mappings_execute.go b/otdfctl/migrations/namespacedpolicy/subject_mappings_execute.go new file mode 100644 index 0000000000..6bf245f3cf --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/subject_mappings_execute.go @@ -0,0 +1,139 @@ +package namespacedpolicy + +import ( + "context" + "fmt" + + "github.com/opentdf/platform/protocol/go/policy" +) + +func (e *Executor) executeSubjectMappings(ctx context.Context, plans []*SubjectMappingPlan) error { + if len(plans) == 0 { + return nil + } + + for _, mappingPlan := range plans { + if mappingPlan == nil || mappingPlan.Source == nil { + continue + } + + // TODO: Need to fix this on the plan. A subject mapping plan should not have multiple targets. + for _, target := range mappingPlan.Targets { + if target == nil { + continue + } + + if err := e.executeSubjectMappingTarget(ctx, mappingPlan, target); err != nil { + return err + } + } + } + + return nil +} + +func (e *Executor) executeSubjectMappingTarget(ctx context.Context, mappingPlan *SubjectMappingPlan, target *SubjectMappingTargetPlan) error { + //nolint:exhaustive // Subject mapping execution only handles create and already-migrated explicitly; all other statuses are unsupported. + switch target.Status { + case TargetStatusAlreadyMigrated: + if target.TargetID() == "" { + return fmt.Errorf("%w: subject mapping %q target %q", ErrMissingMigratedTarget, mappingPlan.Source.GetId(), namespaceLabel(target.Namespace)) + } + return nil + case TargetStatusCreate: + return e.createSubjectMappingTarget(ctx, mappingPlan, target) + case TargetStatusUnresolved: + return fmt.Errorf("%w: subject mapping %q target %q is unresolved: %s", ErrPlanNotExecutable, mappingPlan.Source.GetId(), namespaceLabel(target.Namespace), target.Reason) + default: + return fmt.Errorf("%w: subject mapping %q target %q has unsupported status %q", ErrUnsupportedStatus, mappingPlan.Source.GetId(), namespaceLabel(target.Namespace), target.Status) + } +} + +func (e *Executor) createSubjectMappingTarget(ctx context.Context, mappingPlan *SubjectMappingPlan, target *SubjectMappingTargetPlan) error { + namespace := namespaceIdentifier(target.Namespace) + if namespace == "" { + return fmt.Errorf("%w: subject mapping %q", ErrTargetNamespaceRequired, mappingPlan.Source.GetId()) + } + + actions, err := e.resolveSubjectMappingActions(mappingPlan, target) + if err != nil { + return err + } + + subjectConditionSetID, err := e.resolveSubjectMappingSubjectConditionSet(mappingPlan, target) + if err != nil { + return err + } + + attributeValueID := mappingPlan.Source.GetAttributeValue().GetId() + if attributeValueID == "" { + return fmt.Errorf("subject mapping %q missing attribute value id", mappingPlan.Source.GetId()) + } + + created, err := e.handler.CreateNewSubjectMapping( + ctx, + attributeValueID, + actions, + subjectConditionSetID, + nil, + metadataForCreate( + mappingPlan.Source.GetId(), + metadataLabels(mappingPlan.Source.GetMetadata()), + e.runID, + ), + namespace, + ) + if err != nil { + target.Execution = &ExecutionResult{ + RunID: e.runID, + Failure: err.Error(), + } + return fmt.Errorf("create subject mapping %q in namespace %q: %w", mappingPlan.Source.GetId(), namespaceLabel(target.Namespace), err) + } + if created.GetId() == "" { + target.Execution = &ExecutionResult{ + RunID: e.runID, + Failure: ErrMissingCreatedTargetID.Error(), + } + return fmt.Errorf("%w: subject mapping %q target %q", ErrMissingCreatedTargetID, mappingPlan.Source.GetId(), namespaceLabel(target.Namespace)) + } + + target.Execution = &ExecutionResult{ + RunID: e.runID, + Applied: true, + CreatedTargetID: created.GetId(), + } + + return nil +} + +func (e *Executor) resolveSubjectMappingActions(mappingPlan *SubjectMappingPlan, target *SubjectMappingTargetPlan) ([]*policy.Action, error) { + actions := make([]*policy.Action, 0, len(target.Actions)) + for _, binding := range target.Actions { + if binding == nil || binding.SourceID == "" { + return nil, fmt.Errorf("%w: subject mapping %q target %q", ErrMissingActionTarget, mappingPlan.Source.GetId(), namespaceLabel(target.Namespace)) + } + + targetID := e.cachedActionTargetID(binding.SourceID, target.Namespace) + if targetID == "" { + return nil, fmt.Errorf("%w: subject mapping %q action %q target %q", ErrMissingActionTarget, mappingPlan.Source.GetId(), binding.SourceID, namespaceLabel(target.Namespace)) + } + + actions = append(actions, &policy.Action{Id: targetID}) + } + + return actions, nil +} + +func (e *Executor) resolveSubjectMappingSubjectConditionSet(mappingPlan *SubjectMappingPlan, target *SubjectMappingTargetPlan) (string, error) { + if target.SubjectConditionSet == nil || target.SubjectConditionSet.SourceID == "" { + return "", fmt.Errorf("%w: subject mapping %q target %q", ErrMissingSubjectConditionSetTarget, mappingPlan.Source.GetId(), namespaceLabel(target.Namespace)) + } + + targetID := e.cachedScsTargetID(target.SubjectConditionSet.SourceID, target.Namespace) + if targetID == "" { + return "", fmt.Errorf("%w: subject mapping %q subject condition set %q target %q", ErrMissingSubjectConditionSetTarget, mappingPlan.Source.GetId(), target.SubjectConditionSet.SourceID, namespaceLabel(target.Namespace)) + } + + return targetID, nil +} diff --git a/otdfctl/migrations/namespacedpolicy/subject_mappings_execute_test.go b/otdfctl/migrations/namespacedpolicy/subject_mappings_execute_test.go new file mode 100644 index 0000000000..71e9cceed9 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/subject_mappings_execute_test.go @@ -0,0 +1,569 @@ +package namespacedpolicy + +import ( + "errors" + "testing" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecuteSubjectMappings(t *testing.T) { + t.Parallel() + + namespace1 := &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"} + errBoom := errors.New("boom") + + tests := []struct { + name string + plan *Plan + handler *mockExecutorHandler + runID string + wantErr *expectedError + assert func(t *testing.T, err error, executor *Executor, handler *mockExecutorHandler, plan *Plan) + }{ + { + name: "creates subject mappings with migrated action and scs ids", + plan: &Plan{ + Scopes: []Scope{ScopeActions, ScopeSubjectConditionSets, ScopeSubjectMappings}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{Id: "action-1", Name: "decrypt"}, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + SubjectConditionSets: []*SubjectConditionSetPlan{ + { + Source: &policy.SubjectConditionSet{ + Id: "scs-1", + }, + Targets: []*SubjectConditionSetTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + SubjectMappings: []*SubjectMappingPlan{ + { + Source: &policy.SubjectMapping{ + Id: "mapping-1", + AttributeValue: &policy.Value{ + Id: "av-1", + }, + Metadata: &common.Metadata{ + Labels: map[string]string{ + "owner": "policy-team", + "env": "dev", + }, + }, + }, + Targets: []*SubjectMappingTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + Actions: []*ActionBinding{ + { + SourceID: "action-1", + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + SubjectConditionSet: &SubjectConditionSetBinding{ + SourceID: "scs-1", + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{ + results: map[string]map[string]*policy.Action{ + "decrypt": { + "ns-1": {Id: "action-target-1"}, + }, + }, + subjectConditionSetResult: map[string]map[string]*policy.SubjectConditionSet{ + "scs-1": { + "ns-1": {Id: "scs-target-1"}, + }, + }, + subjectMappingResults: map[string]map[string]*policy.SubjectMapping{ + "mapping-1": { + "ns-1": {Id: "mapping-target-1"}, + }, + }, + }, + runID: "run-789", + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.NoError(t, err) + require.Contains(t, handler.createdSubjectMappings, "mapping-1") + require.Contains(t, handler.createdSubjectMappings["mapping-1"], "ns-1") + call := handler.createdSubjectMappings["mapping-1"]["ns-1"] + assert.Equal(t, "av-1", call.AttributeValueID) + require.Len(t, call.Actions, 1) + assert.Equal(t, "action-target-1", call.Actions[0].GetId()) + assert.Equal(t, "scs-target-1", call.ExistingSubjectConditionSet) + assert.Nil(t, call.NewSubjectConditionSet) + assert.Equal(t, "ns-1", call.Namespace) + assert.Equal(t, map[string]string{ + "owner": "policy-team", + "env": "dev", + migrationLabelMigratedFrom: "mapping-1", + migrationLabelRun: "run-789", + }, call.Metadata.GetLabels()) + + target := plan.SubjectMappings[0].Targets[0] + assert.Equal(t, TargetStatusCreate, target.Status) + assert.Nil(t, target.Existing) + require.NotNil(t, target.Execution) + assert.True(t, target.Execution.Applied) + assert.Equal(t, "mapping-target-1", target.Execution.CreatedTargetID) + assert.Equal(t, "run-789", target.Execution.RunID) + assert.Equal(t, "mapping-target-1", target.TargetID()) + }, + }, + { + name: "skips already migrated subject mapping targets", + plan: &Plan{ + Scopes: []Scope{ScopeSubjectMappings}, + SubjectMappings: []*SubjectMappingPlan{ + { + Source: &policy.SubjectMapping{Id: "mapping-1"}, + Targets: []*SubjectMappingTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + Existing: &policy.SubjectMapping{Id: "mapping-target-1"}, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.NoError(t, err) + assert.Empty(t, handler.createdSubjectMappings) + assert.Equal(t, "mapping-target-1", plan.SubjectMappings[0].Targets[0].TargetID()) + assert.Nil(t, plan.SubjectMappings[0].Targets[0].Execution) + }, + }, + { + name: "returns not executable for unresolved target status", + plan: &Plan{ + Scopes: []Scope{ScopeSubjectMappings}, + SubjectMappings: []*SubjectMappingPlan{ + { + Source: &policy.SubjectMapping{Id: "mapping-1"}, + Targets: []*SubjectMappingTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusUnresolved, + Reason: "missing target namespace mapping", + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError( + ErrPlanNotExecutable, + `subject mapping %q target %q is unresolved: %s`, + "mapping-1", + namespace1.GetFqn(), + "missing target namespace mapping", + ), + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.createdSubjectMappings) + }, + }, + { + name: "returns error for missing already migrated target id", + plan: &Plan{ + Scopes: []Scope{ScopeSubjectMappings}, + SubjectMappings: []*SubjectMappingPlan{ + { + Source: &policy.SubjectMapping{Id: "mapping-1"}, + Targets: []*SubjectMappingTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError(ErrMissingMigratedTarget, `subject mapping %q target %q`, "mapping-1", namespace1.GetFqn()), + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.createdSubjectMappings) + }, + }, + { + name: "returns error for missing action target id", + plan: &Plan{ + Scopes: []Scope{ScopeSubjectMappings}, + SubjectMappings: []*SubjectMappingPlan{ + { + Source: &policy.SubjectMapping{ + Id: "mapping-1", + AttributeValue: &policy.Value{ + Id: "av-1", + }, + }, + Targets: []*SubjectMappingTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + Actions: []*ActionBinding{ + { + SourceID: "action-1", + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + SubjectConditionSet: &SubjectConditionSetBinding{ + SourceID: "scs-1", + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + }, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError(ErrMissingActionTarget, `subject mapping %q action %q target %q`, "mapping-1", "action-1", namespace1.GetFqn()), + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.createdSubjectMappings) + }, + }, + { + name: "returns error for missing scs target id", + plan: &Plan{ + Scopes: []Scope{ScopeActions, ScopeSubjectMappings}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{Id: "action-1", Name: "decrypt"}, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + Existing: &policy.Action{Id: "action-target-1"}, + }, + }, + }, + }, + SubjectMappings: []*SubjectMappingPlan{ + { + Source: &policy.SubjectMapping{ + Id: "mapping-1", + AttributeValue: &policy.Value{ + Id: "av-1", + }, + }, + Targets: []*SubjectMappingTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + Actions: []*ActionBinding{ + { + SourceID: "action-1", + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + }, + }, + SubjectConditionSet: &SubjectConditionSetBinding{ + SourceID: "scs-1", + Namespace: namespace1, + Status: TargetStatusCreate, + }, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError(ErrMissingSubjectConditionSetTarget, `subject mapping %q subject condition set %q target %q`, "mapping-1", "scs-1", namespace1.GetFqn()), + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.createdSubjectMappings) + }, + }, + { + name: "returns error for missing target namespace", + plan: &Plan{ + Scopes: []Scope{ScopeSubjectMappings}, + SubjectMappings: []*SubjectMappingPlan{ + { + Source: &policy.SubjectMapping{ + Id: "mapping-1", + AttributeValue: &policy.Value{ + Id: "av-1", + }, + }, + Targets: []*SubjectMappingTargetPlan{ + { + Status: TargetStatusCreate, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError(ErrTargetNamespaceRequired, `subject mapping %q`, "mapping-1"), + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.createdSubjectMappings) + }, + }, + { + name: "returns error for missing created target id", + plan: &Plan{ + Scopes: []Scope{ScopeActions, ScopeSubjectConditionSets, ScopeSubjectMappings}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{Id: "action-1", Name: "decrypt"}, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + Existing: &policy.Action{Id: "action-target-1"}, + }, + }, + }, + }, + SubjectConditionSets: []*SubjectConditionSetPlan{ + { + Source: &policy.SubjectConditionSet{Id: "scs-1"}, + Targets: []*SubjectConditionSetTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + Existing: &policy.SubjectConditionSet{Id: "scs-target-1"}, + }, + }, + }, + }, + SubjectMappings: []*SubjectMappingPlan{ + { + Source: &policy.SubjectMapping{ + Id: "mapping-1", + AttributeValue: &policy.Value{ + Id: "av-1", + }, + }, + Targets: []*SubjectMappingTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + Actions: []*ActionBinding{ + { + SourceID: "action-1", + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + }, + }, + SubjectConditionSet: &SubjectConditionSetBinding{ + SourceID: "scs-1", + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + }, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{ + subjectMappingResults: map[string]map[string]*policy.SubjectMapping{ + "mapping-1": { + "ns-1": {}, + }, + }, + }, + wantErr: wantError(ErrMissingCreatedTargetID, `subject mapping %q target %q`, "mapping-1", namespace1.GetFqn()), + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.Error(t, err) + require.Contains(t, handler.createdSubjectMappings, "mapping-1") + require.NotNil(t, plan.SubjectMappings[0].Targets[0].Execution) + assert.Equal(t, ErrMissingCreatedTargetID.Error(), plan.SubjectMappings[0].Targets[0].Execution.Failure) + }, + }, + { + name: "returns error for unsupported target status", + plan: &Plan{ + Scopes: []Scope{ScopeSubjectMappings}, + SubjectMappings: []*SubjectMappingPlan{ + { + Source: &policy.SubjectMapping{Id: "mapping-1"}, + Targets: []*SubjectMappingTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatus("bogus"), + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{}, + wantErr: wantError( + ErrUnsupportedStatus, + `subject mapping %q target %q has unsupported status %q`, + "mapping-1", + namespace1.GetFqn(), + TargetStatus("bogus"), + ), + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, _ *Plan) { + t.Helper() + + require.Error(t, err) + assert.Empty(t, handler.createdSubjectMappings) + }, + }, + { + name: "records create failures on the target", + plan: &Plan{ + Scopes: []Scope{ScopeActions, ScopeSubjectConditionSets, ScopeSubjectMappings}, + Actions: []*ActionPlan{ + { + Source: &policy.Action{Id: "action-1", Name: "decrypt"}, + Targets: []*ActionTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + Existing: &policy.Action{Id: "action-target-1"}, + }, + }, + }, + }, + SubjectConditionSets: []*SubjectConditionSetPlan{ + { + Source: &policy.SubjectConditionSet{Id: "scs-1"}, + Targets: []*SubjectConditionSetTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + Existing: &policy.SubjectConditionSet{Id: "scs-target-1"}, + }, + }, + }, + }, + SubjectMappings: []*SubjectMappingPlan{ + { + Source: &policy.SubjectMapping{ + Id: "mapping-1", + AttributeValue: &policy.Value{ + Id: "av-1", + }, + }, + Targets: []*SubjectMappingTargetPlan{ + { + Namespace: namespace1, + Status: TargetStatusCreate, + Actions: []*ActionBinding{ + { + SourceID: "action-1", + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + }, + }, + SubjectConditionSet: &SubjectConditionSetBinding{ + SourceID: "scs-1", + Namespace: namespace1, + Status: TargetStatusAlreadyMigrated, + }, + }, + }, + }, + }, + }, + handler: &mockExecutorHandler{ + subjectMappingErrs: map[string]map[string]error{ + "mapping-1": { + "ns-1": errBoom, + }, + }, + }, + wantErr: &expectedError{ + is: errBoom, + message: `create subject mapping "mapping-1" in namespace "https://example.com": boom`, + }, + assert: func(t *testing.T, err error, _ *Executor, handler *mockExecutorHandler, plan *Plan) { + t.Helper() + + require.Error(t, err) + require.Contains(t, handler.createdSubjectMappings, "mapping-1") + require.NotNil(t, plan.SubjectMappings[0].Targets[0].Execution) + assert.Equal(t, "boom", plan.SubjectMappings[0].Targets[0].Execution.Failure) + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + executor, err := NewExecutor(tt.handler) + require.NoError(t, err) + if tt.runID != "" { + executor.runID = tt.runID + } + + err = executor.Execute(t.Context(), tt.plan) + switch { + case tt.wantErr != nil: + require.Error(t, err) + require.ErrorIs(t, err, tt.wantErr.is) + require.EqualError(t, err, tt.wantErr.message) + default: + require.NoError(t, err) + } + + tt.assert(t, err, executor, tt.handler, tt.plan) + }) + } +} + +func TestSubjectMappingTargetIDPrefersExecutionResult(t *testing.T) { + t.Parallel() + + target := &SubjectMappingTargetPlan{ + Namespace: &policy.Namespace{Id: "ns-2", Fqn: "https://example.net"}, + Existing: &policy.SubjectMapping{Id: "existing-target"}, + Execution: &ExecutionResult{ + CreatedTargetID: "created-target", + }, + } + + assert.Equal(t, "created-target", target.TargetID()) +} diff --git a/otdfctl/migrations/namespacedpolicy/test_helpers_test.go b/otdfctl/migrations/namespacedpolicy/test_helpers_test.go new file mode 100644 index 0000000000..815f661cb6 --- /dev/null +++ b/otdfctl/migrations/namespacedpolicy/test_helpers_test.go @@ -0,0 +1,43 @@ +package namespacedpolicy + +import "github.com/opentdf/platform/protocol/go/policy" + +func testNamespace(fqn string) *policy.Namespace { + return &policy.Namespace{ + Fqn: fqn, + } +} + +func testAttributeValue(fqn string, namespace *policy.Namespace) *policy.Value { + return &policy.Value{ + Fqn: fqn, + Attribute: &policy.Attribute{ + Namespace: namespace, + }, + } +} + +func testActionAttributeValue(actionID, actionName string, attributeValue *policy.Value) *policy.RegisteredResourceValue_ActionAttributeValue { + return &policy.RegisteredResourceValue_ActionAttributeValue{ + Action: &policy.Action{ + Id: actionID, + Name: actionName, + }, + AttributeValue: attributeValue, + } +} + +func testRegisteredResourceValue(value string, aavs ...*policy.RegisteredResourceValue_ActionAttributeValue) *policy.RegisteredResourceValue { + return &policy.RegisteredResourceValue{ + Value: value, + ActionAttributeValues: aavs, + } +} + +func testRegisteredResource(id, name string, values ...*policy.RegisteredResourceValue) *policy.RegisteredResource { + return &policy.RegisteredResource{ + Id: id, + Name: name, + Values: values, + } +} diff --git a/otdfctl/migrations/registered-resources.go b/otdfctl/migrations/registered-resources.go new file mode 100644 index 0000000000..666c480f7c --- /dev/null +++ b/otdfctl/migrations/registered-resources.go @@ -0,0 +1,840 @@ +//nolint:forbidigo // migration output requires direct terminal printing for interactive prompts and styled output +package migrations + +import ( + "context" + "errors" + "fmt" + "math" + + "github.com/charmbracelet/huh" + "github.com/opentdf/platform/lib/identifier" + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/namespaces" + "github.com/opentdf/platform/protocol/go/policy/registeredresources" +) + +const ( + optSkipResource = "skip-resource" + optAbortAll = "abort-all" +) + +// MigrationHandler defines the handler methods needed for registered resource migration. +// handlers.Handler satisfies this interface implicitly. +type MigrationHandler interface { + ListRegisteredResources(ctx context.Context, limit, offset int32, namespace string) (*registeredresources.ListRegisteredResourcesResponse, error) + ListRegisteredResourceValues(ctx context.Context, resourceID string, limit, offset int32) (*registeredresources.ListRegisteredResourceValuesResponse, error) + CreateRegisteredResource(ctx context.Context, namespace, name string, values []string, metadata *common.MetadataMutable) (*policy.RegisteredResource, error) + CreateRegisteredResourceValue(ctx context.Context, resourceID string, value string, actionAttributeValues []*registeredresources.ActionAttributeValue, metadata *common.MetadataMutable) (*policy.RegisteredResourceValue, error) + DeleteRegisteredResource(ctx context.Context, id string) error + ListNamespaces(ctx context.Context, state common.ActiveStateEnum, limit, offset int32) (*namespaces.ListNamespacesResponse, error) +} + +// MigrationPrompter abstracts interactive prompts so they can be mocked in tests. +type MigrationPrompter interface { + // ConfirmBackup prompts the user to confirm they have taken a backup. + ConfirmBackup() (bool, error) + + // SelectBatchNamespace prompts the user to select one namespace for all resources. + SelectBatchNamespace(nsList []*policy.Namespace) (string, error) + + // SelectResourceNamespace prompts the user to select a namespace for a specific resource. + // The returned string may be a namespace FQN/ID, optSkipResource, or optAbortAll. + SelectResourceNamespace(resourceName string, nsList []*policy.Namespace) (string, error) + + // ConfirmResourceNamespace shows the auto-detected namespace and asks the user to confirm, + // skip the resource, or abort. Returns the namespace FQN, optSkipResource, or optAbortAll. + ConfirmResourceNamespace(resourceName, detectedNamespaceFQN string) (string, error) +} + +// HuhPrompter implements MigrationPrompter using charmbracelet/huh forms. +type HuhPrompter struct{} + +func (p *HuhPrompter) ConfirmBackup() (bool, error) { + var backupResponse bool + styles := initMigrationDisplayStyles() + + fmt.Println(styles.styleWarning.Render("WARNING: This operation will delete and re-create registered resources under new namespaces.")) + fmt.Println(styles.styleWarning.Render("It is STRONGLY recommended to take a complete backup of your system before proceeding.\n")) + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[bool](). + Title("Have you taken a complete backup? (yes/no): "). + Options( + huh.NewOption("yes", true), + huh.NewOption("no", false), + huh.NewOption("cancel", false), + ). + Value(&backupResponse), + ), + ) + + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return false, errors.New("user aborted backup form") + } + return false, err + } + return backupResponse, nil +} + +func (p *HuhPrompter) SelectBatchNamespace(nsList []*policy.Namespace) (string, error) { + var targetNamespace string + nsOpts := buildNamespaceOptions(nsList) + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Select a target namespace for ALL registered resources:"). + Options(nsOpts...). + Value(&targetNamespace), + ), + ) + + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return "", errors.New("migration aborted by user") + } + return "", fmt.Errorf("namespace selection failed: %w", err) + } + return targetNamespace, nil +} + +func (p *HuhPrompter) SelectResourceNamespace(resourceName string, nsList []*policy.Namespace) (string, error) { + nsOpts := buildNamespaceOptions(nsList) + skipOpt := huh.NewOption("Skip this resource", optSkipResource) + abortOpt := huh.NewOption("Abort entire migration", optAbortAll) + nsOptsWithControls := append(append([]huh.Option[string]{}, nsOpts...), skipOpt, abortOpt) + + var targetNamespace string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(fmt.Sprintf("Select namespace for resource '%s':", resourceName)). + Options(nsOptsWithControls...). + Value(&targetNamespace), + ), + ) + + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return "", huh.ErrUserAborted + } + return "", err + } + return targetNamespace, nil +} + +func (p *HuhPrompter) ConfirmResourceNamespace(resourceName, detectedNamespaceFQN string) (string, error) { + var choice string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Resource '"+resourceName+"' belongs in namespace '"+detectedNamespaceFQN+"' (detected from AAVs):"). + Options( + huh.NewOption("Confirm: "+detectedNamespaceFQN, detectedNamespaceFQN), + huh.NewOption("Skip this resource", optSkipResource), + huh.NewOption("Abort entire migration", optAbortAll), + ). + Value(&choice), + ), + ) + + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return "", huh.ErrUserAborted + } + return "", err + } + return choice, nil +} + +// RegisteredResourceMigrationPlan holds an existing resource with its values and the target namespace. +type RegisteredResourceMigrationPlan struct { + Resource *policy.RegisteredResource + Values []*policy.RegisteredResourceValue + TargetNamespace string // namespace FQN or ID to migrate to + Commit bool +} + +// namespaceDetectionResult holds the result of inspecting a resource's AAVs for namespace info. +type namespaceDetectionResult struct { + Deterministic string // single namespace FQN if all AAVs agree + Conflicting []string // distinct FQNs when AAVs reference multiple namespaces + Undetermined bool // AAVs exist but namespace data unavailable + NoAAVs bool // resource has no AAVs at all +} + +// extractNamespaceFQNFromValue attempts to derive the namespace FQN from an attribute value. +func extractNamespaceFQNFromValue(val *policy.Value) string { + // Primary: full chain Value → Attribute → Namespace + if ns := val.GetAttribute().GetNamespace(); ns != nil { + if fqn := ns.GetFqn(); fqn != "" { + return fqn + } + } + // Fallback: parse from the value's own FQN (e.g. "https://example.com/attr/color/value/red") + if fqn := val.GetFqn(); fqn != "" { + if parsed, err := identifier.Parse[*identifier.FullyQualifiedAttribute](fqn); err == nil && parsed.Namespace != "" { + return "https://" + parsed.Namespace + } + } + return "" +} + +// detectRequiredNamespace inspects a resource's AAVs to determine which namespace it should belong to. +func detectRequiredNamespace(plan RegisteredResourceMigrationPlan) namespaceDetectionResult { + nsSet := make(map[string]struct{}) + hasAAVs := false + + for _, v := range plan.Values { + for _, aav := range v.GetActionAttributeValues() { + hasAAVs = true + attrVal := aav.GetAttributeValue() + if attrVal == nil { + continue + } + nsFQN := extractNamespaceFQNFromValue(attrVal) + if nsFQN != "" { + nsSet[nsFQN] = struct{}{} + } + } + } + + if !hasAAVs { + return namespaceDetectionResult{NoAAVs: true} + } + + if len(nsSet) == 0 { + return namespaceDetectionResult{Undetermined: true} + } + + if len(nsSet) == 1 { + for fqn := range nsSet { + return namespaceDetectionResult{Deterministic: fqn} + } + } + + conflicting := make([]string, 0, len(nsSet)) + for fqn := range nsSet { + conflicting = append(conflicting, fqn) + } + return namespaceDetectionResult{Conflicting: conflicting} +} + +// filterNamespacesByFQN returns only the namespaces whose FQN matches one of the given FQNs. +func filterNamespacesByFQN(nsList []*policy.Namespace, fqns []string) []*policy.Namespace { + fqnSet := make(map[string]struct{}, len(fqns)) + for _, f := range fqns { + fqnSet[f] = struct{}{} + } + var filtered []*policy.Namespace + for _, ns := range nsList { + if _, ok := fqnSet[ns.GetFqn()]; ok { + filtered = append(filtered, ns) + } + } + return filtered +} + +// MigrateRegisteredResources is the main entry point for migrating registered resources to namespaces. +func MigrateRegisteredResources(ctx context.Context, h MigrationHandler, prompter MigrationPrompter, commit, interactive bool) error { + styles := initMigrationDisplayStyles() + + plan, err := buildRegisteredResourcePlan(ctx, h) + if err != nil { + return err + } + + if len(plan) == 0 { + fmt.Println(styles.styleWarning.Render("No registered resources found that need namespace migration.")) + return nil + } + + availableNamespaces, err := listAvailableNamespaces(ctx, h) + if err != nil { + return err + } + + if len(availableNamespaces) == 0 { + return errors.New("no namespaces available - please create at least one namespace before running migration") + } + + if commit { + didBackup, err := prompter.ConfirmBackup() + if err != nil { + return err + } + if !didBackup { + return errors.New("user did not confirm backup") + } + } + + switch { + case interactive && commit: + return runInteractiveRegisteredResourceMigration(ctx, h, prompter, styles, plan, availableNamespaces) + case commit: + return runBatchRegisteredResourceMigration(ctx, h, prompter, styles, plan, availableNamespaces) + default: + displayRegisteredResourcePlan(styles, plan) + if interactive { + fmt.Println(styles.styleInfo.Render("\nNote: --interactive without --commit only shows a preview. Add --commit to apply changes.")) + } + } + + return nil +} + +// buildRegisteredResourcePlan fetches all registered resources without namespaces and their values. +func buildRegisteredResourcePlan(ctx context.Context, h MigrationHandler) ([]RegisteredResourceMigrationPlan, error) { + var ( + plans []RegisteredResourceMigrationPlan + offset int32 + pageSize int32 = 100 + ) + + for { + resp, err := h.ListRegisteredResources(ctx, pageSize, offset, "") + if err != nil { + return nil, fmt.Errorf("failed to list registered resources: %w", err) + } + + resources := resp.GetResources() + if len(resources) == 0 { + break + } + + for _, resource := range resources { + // Only include resources that have no namespace + if resource.GetNamespace() != nil && resource.GetNamespace().GetId() != "" { + continue + } + + values, err := fetchAllResourceValues(ctx, h, resource.GetId()) + if err != nil { + return nil, fmt.Errorf("failed to fetch values for resource %s: %w", resource.GetId(), err) + } + + plans = append(plans, RegisteredResourceMigrationPlan{ + Resource: resource, + Values: values, + }) + } + + qty := len(resources) + if qty > math.MaxInt32 || offset+int32(qty) < 0 { + return nil, errors.New("resource count exceeded safe limit") + } + offset += int32(qty) + + if int32(qty) < pageSize { + break + } + } + + return plans, nil +} + +// fetchAllResourceValues paginates through all values for a resource. +func fetchAllResourceValues(ctx context.Context, h MigrationHandler, resourceID string) ([]*policy.RegisteredResourceValue, error) { + var ( + allValues []*policy.RegisteredResourceValue + offset int32 + pageSize int32 = 100 + ) + + for { + resp, err := h.ListRegisteredResourceValues(ctx, resourceID, pageSize, offset) + if err != nil { + return nil, err + } + + values := resp.GetValues() + if len(values) == 0 { + break + } + + allValues = append(allValues, values...) + + qty := len(values) + if qty > math.MaxInt32 || offset+int32(qty) < 0 { + break + } + offset += int32(qty) + + if int32(qty) < pageSize { + break + } + } + + return allValues, nil +} + +// listAvailableNamespaces fetches all active namespaces. +func listAvailableNamespaces(ctx context.Context, h MigrationHandler) ([]*policy.Namespace, error) { + var ( + all []*policy.Namespace + offset int32 + pageSize int32 = 100 + ) + + for { + resp, err := h.ListNamespaces(ctx, common.ActiveStateEnum_ACTIVE_STATE_ENUM_ACTIVE, pageSize, offset) + if err != nil { + return nil, fmt.Errorf("failed to list namespaces: %w", err) + } + + nsList := resp.GetNamespaces() + if len(nsList) == 0 { + break + } + + all = append(all, nsList...) + + qty := len(nsList) + if qty > math.MaxInt32 || offset+int32(qty) < 0 { + break + } + offset += int32(qty) + + if int32(qty) < pageSize { + break + } + } + + return all, nil +} + +// buildNamespaceOptions creates huh options from a list of namespaces. +func buildNamespaceOptions(nsList []*policy.Namespace) []huh.Option[string] { + opts := make([]huh.Option[string], 0, len(nsList)) + for _, ns := range nsList { + label := ns.GetFqn() + value := ns.GetFqn() + if label == "" { + label = ns.GetName() + " (" + ns.GetId() + ")" + value = ns.GetId() + } + opts = append(opts, huh.NewOption(label, value)) + } + return opts +} + +// displayRegisteredResourcePlan shows a preview of resources that would be migrated. +func displayRegisteredResourcePlan(styles *migrationDisplayStyles, plan []RegisteredResourceMigrationPlan) { + fmt.Println(styles.styleTitle.Render("\nRegistered Resources Migration Plan")) + fmt.Println(styles.styleSeparator.Render(styles.separatorText)) + fmt.Printf("%s %d\n\n", + styles.styleInfo.Render("Resources requiring namespace assignment:"), + len(plan), + ) + + for i, p := range plan { + fmt.Printf("%s %s\n", + styles.styleInfo.Render(fmt.Sprintf("%d. Resource ID:", i+1)), + styles.styleResourceID.Render(p.Resource.GetId()), + ) + fmt.Printf(" %s %s\n", + styles.styleInfo.Render("Name:"), + styles.styleName.Render(p.Resource.GetName()), + ) + if len(p.Values) > 0 { + fmt.Printf(" %s\n", styles.styleInfo.Render("Values:")) + for _, v := range p.Values { + aavCount := len(v.GetActionAttributeValues()) + fmt.Printf(" - %s (ID: %s, %d action-attribute mapping(s))\n", + styles.styleValue.Render(v.GetValue()), + styles.styleID.Render(v.GetId()), + aavCount, + ) + } + } else { + fmt.Printf(" %s\n", styles.styleInfo.Render("Values: (none)")) + } + + detection := detectRequiredNamespace(p) + switch { + case detection.Deterministic != "": + fmt.Printf(" %s %s\n", + styles.styleInfo.Render("Detected namespace:"), + styles.styleNamespace.Render(detection.Deterministic), + ) + case len(detection.Conflicting) > 0: + fmt.Printf(" %s %v\n", + styles.styleWarning.Render("CONFLICT - AAVs reference multiple namespaces:"), + detection.Conflicting, + ) + case detection.Undetermined: + fmt.Printf(" %s\n", + styles.styleWarning.Render("AAVs present but namespace could not be determined"), + ) + default: + fmt.Printf(" %s\n", + styles.styleInfo.Render("No AAVs - namespace can be freely chosen"), + ) + } + + fmt.Println() + } + + fmt.Println(styles.styleSeparator.Render(styles.separatorText)) + fmt.Println(styles.styleInfo.Render("\nRun with --commit to assign namespaces in batch mode.")) + fmt.Println(styles.styleInfo.Render("Run with --interactive --commit for per-resource namespace assignment.")) +} + +type indexedPlan struct { + index int + plan RegisteredResourceMigrationPlan +} + +// runBatchRegisteredResourceMigration auto-detects namespaces where possible and prompts for the rest. +func runBatchRegisteredResourceMigration(ctx context.Context, h MigrationHandler, prompter MigrationPrompter, styles *migrationDisplayStyles, plan []RegisteredResourceMigrationPlan, nsList []*policy.Namespace) error { + displayRegisteredResourcePlan(styles, plan) + + // Phase 1: Auto-detect namespaces + var needsSelection []indexedPlan + + fmt.Println(styles.styleTitle.Render("\nNamespace Detection:")) + for i := range plan { + detection := detectRequiredNamespace(plan[i]) + switch { + case detection.Deterministic != "": + plan[i].TargetNamespace = detection.Deterministic + fmt.Printf(" %s '%s' -> %s (auto-detected from AAVs)\n", + styles.styleInfo.Render("Resource"), + styles.styleName.Render(plan[i].Resource.GetName()), + styles.styleNamespace.Render(detection.Deterministic), + ) + case len(detection.Conflicting) > 0: + fmt.Printf(" %s '%s' has AAVs in multiple namespaces: %v\n", + styles.styleWarning.Render("CONFLICT: Resource"), + plan[i].Resource.GetName(), detection.Conflicting, + ) + needsSelection = append(needsSelection, indexedPlan{index: i, plan: plan[i]}) + case detection.Undetermined: + fmt.Printf(" %s '%s' has AAVs but namespace could not be determined\n", + styles.styleWarning.Render("WARNING: Resource"), + plan[i].Resource.GetName(), + ) + needsSelection = append(needsSelection, indexedPlan{index: i, plan: plan[i]}) + default: + fmt.Printf(" %s '%s' has no AAVs - needs manual assignment\n", + styles.styleInfo.Render("Resource"), + plan[i].Resource.GetName(), + ) + needsSelection = append(needsSelection, indexedPlan{index: i, plan: plan[i]}) + } + } + + // Phase 2: Prompt for resources that need selection + if err := resolveUndetectedNamespaces(styles, prompter, plan, needsSelection, nsList); err != nil { + return err + } + + // Phase 3: Execute all migrations + successCount := 0 + skippedCount := 0 + failedResources := make(map[string]string) + + for _, p := range plan { + if p.TargetNamespace == "" { + skippedCount++ + continue + } + p.Commit = true + + fmt.Printf("%s %s (%s) to %s...\n", + styles.styleInfo.Render("Migrating resource"), + styles.styleName.Render(p.Resource.GetName()), + styles.styleResourceID.Render(p.Resource.GetId()), + styles.styleNamespace.Render(p.TargetNamespace), + ) + + if err := commitRegisteredResourceMigration(ctx, h, p); err != nil { + errMsg := "Failed to migrate resource " + p.Resource.GetId() + ": " + err.Error() + fmt.Println(styles.styleWarning.Render(errMsg)) + failedResources[p.Resource.GetId()] = err.Error() + } else { + fmt.Println(styles.styleAction.Render(" Successfully migrated resource " + p.Resource.GetName())) + successCount++ + } + } + + // Print summary + fmt.Println(styles.styleTitle.Render("\nBatch Migration Summary:")) + fmt.Printf(" Total Resources: %d\n", len(plan)) + fmt.Printf(" Successfully Migrated: %d\n", successCount) + fmt.Printf(" Skipped: %d\n", skippedCount) + fmt.Printf(" Failed: %d\n", len(failedResources)) + if len(failedResources) > 0 { + fmt.Println(styles.styleWarning.Render(" Failed Resources:")) + for id, errMsg := range failedResources { + fmt.Printf(" - Resource ID %s: %s\n", styles.styleResourceID.Render(id), errMsg) + } + return fmt.Errorf("%d of %d resources failed to migrate", len(failedResources), len(plan)) + } + + return nil +} + +// resolveUndetectedNamespaces prompts for namespace selection on resources where auto-detection was not possible. +func resolveUndetectedNamespaces(styles *migrationDisplayStyles, prompter MigrationPrompter, plan []RegisteredResourceMigrationPlan, needsSelection []indexedPlan, nsList []*policy.Namespace) error { + if len(needsSelection) == 0 { + return nil + } + + allNoAAVs := true + for _, ip := range needsSelection { + d := detectRequiredNamespace(ip.plan) + if !d.NoAAVs { + allNoAAVs = false + break + } + } + + if allNoAAVs { + fmt.Printf("\n%s\n", styles.styleInfo.Render(fmt.Sprintf( + "%d resource(s) have no AAVs and can be freely assigned:", len(needsSelection), + ))) + batchNs, err := prompter.SelectBatchNamespace(nsList) + if err != nil { + return err + } + for _, ip := range needsSelection { + plan[ip.index].TargetNamespace = batchNs + } + return nil + } + + fmt.Printf("\n%s\n", styles.styleInfo.Render(fmt.Sprintf( + "%d resource(s) need manual namespace selection:", len(needsSelection), + ))) + for _, ip := range needsSelection { + detection := detectRequiredNamespace(ip.plan) + promptNsList := nsList + if len(detection.Conflicting) > 0 { + filtered := filterNamespacesByFQN(nsList, detection.Conflicting) + if len(filtered) > 0 { + promptNsList = filtered + } + } + ns, err := prompter.SelectResourceNamespace(ip.plan.Resource.GetName(), promptNsList) + if err != nil { + return err + } + if ns == optAbortAll { + return errors.New("migration aborted by user") + } + if ns == optSkipResource { + continue // TargetNamespace remains empty + } + plan[ip.index].TargetNamespace = ns + } + return nil +} + +// runInteractiveRegisteredResourceMigration prompts per-resource for namespace assignment. +func runInteractiveRegisteredResourceMigration(ctx context.Context, h MigrationHandler, prompter MigrationPrompter, styles *migrationDisplayStyles, plan []RegisteredResourceMigrationPlan, nsList []*policy.Namespace) error { + fmt.Println(styles.styleInfo.Render("Interactive mode: processing resources one by one...")) + + var ( + successCount int + skippedCount int + aborted bool + failedResources = make(map[string]string) + ) + + for i, p := range plan { + fmt.Println(styles.styleSeparator.Render(styles.separatorText)) + fmt.Printf("%s %s (%s %s)\n", + styles.styleTitle.Render(fmt.Sprintf("Resource %d/%d:", i+1, len(plan))), + styles.styleName.Render(p.Resource.GetName()), + styles.styleInfo.Render("ID:"), + styles.styleResourceID.Render(p.Resource.GetId()), + ) + + if len(p.Values) > 0 { + fmt.Printf(" %s\n", styles.styleInfo.Render("Values:")) + for _, v := range p.Values { + aavCount := len(v.GetActionAttributeValues()) + fmt.Printf(" - %s (%d action-attribute mapping(s))\n", + styles.styleValue.Render(v.GetValue()), + aavCount, + ) + } + } + + detection := detectRequiredNamespace(p) + + var targetNamespace string + var promptErr error + + switch { + case detection.Deterministic != "": + fmt.Printf(" %s %s\n", + styles.styleInfo.Render("Detected required namespace from AAVs:"), + styles.styleNamespace.Render(detection.Deterministic), + ) + targetNamespace, promptErr = prompter.ConfirmResourceNamespace(p.Resource.GetName(), detection.Deterministic) + case len(detection.Conflicting) > 0: + fmt.Println(styles.styleWarning.Render(fmt.Sprintf( + " WARNING: Resource '%s' has AAVs referencing multiple namespaces: %v", + p.Resource.GetName(), detection.Conflicting, + ))) + filtered := filterNamespacesByFQN(nsList, detection.Conflicting) + if len(filtered) == 0 { + filtered = nsList + } + targetNamespace, promptErr = prompter.SelectResourceNamespace(p.Resource.GetName(), filtered) + case detection.Undetermined: + fmt.Println(styles.styleWarning.Render(fmt.Sprintf( + " WARNING: Resource '%s' has AAVs but namespace could not be determined from server response.", + p.Resource.GetName(), + ))) + targetNamespace, promptErr = prompter.SelectResourceNamespace(p.Resource.GetName(), nsList) + default: + targetNamespace, promptErr = prompter.SelectResourceNamespace(p.Resource.GetName(), nsList) + } + + if promptErr != nil { + if errors.Is(promptErr, huh.ErrUserAborted) { + fmt.Println(styles.styleWarning.Render("Migration aborted by user.")) + aborted = true + break + } + fmt.Println(styles.styleWarning.Render(fmt.Sprintf("Error during prompt: %v. Skipping resource.", promptErr))) + skippedCount++ + continue + } + + switch targetNamespace { + case optSkipResource: + fmt.Println(styles.styleInfo.Render(fmt.Sprintf("Skipping resource %s.", p.Resource.GetName()))) + skippedCount++ + continue + case optAbortAll: + fmt.Println(styles.styleWarning.Render("Aborting migration.")) + aborted = true + goto summary + } + + p.TargetNamespace = targetNamespace + p.Commit = true + + fmt.Printf("%s %s to namespace %s...\n", + styles.styleAction.Render(" Migrating"), + styles.styleName.Render(p.Resource.GetName()), + styles.styleNamespace.Render(targetNamespace), + ) + + if err := commitRegisteredResourceMigration(ctx, h, p); err != nil { + errMsg := "Failed to migrate resource " + p.Resource.GetId() + ": " + err.Error() + fmt.Println(styles.styleWarning.Render(errMsg)) + failedResources[p.Resource.GetId()] = err.Error() + } else { + fmt.Println(styles.styleAction.Render(" Successfully migrated resource " + p.Resource.GetName())) + successCount++ + } + } + +summary: + fmt.Println(styles.styleTitle.Render("\nInteractive Migration Summary:")) + fmt.Printf(" Total Resources: %d\n", len(plan)) + fmt.Printf(" Successfully Migrated: %d\n", successCount) + fmt.Printf(" Skipped: %d\n", skippedCount) + fmt.Printf(" Failed: %d\n", len(failedResources)) + if len(failedResources) > 0 { + fmt.Println(styles.styleWarning.Render(" Failed Resources:")) + for id, errMsg := range failedResources { + fmt.Printf(" - Resource ID %s: %s\n", styles.styleResourceID.Render(id), errMsg) + } + } + + if aborted { + return errors.New("migration aborted by user") + } + if len(failedResources) > 0 { + return fmt.Errorf("%d of %d resources failed to migrate", len(failedResources), len(plan)) + } + + return nil +} + +// commitRegisteredResourceMigration re-creates a resource under a target namespace, then deletes the old one. +func commitRegisteredResourceMigration(ctx context.Context, h MigrationHandler, plan RegisteredResourceMigrationPlan) error { + if !plan.Commit || plan.TargetNamespace == "" { + return errors.New("migration plan is not ready for commit") + } + + resource := plan.Resource + + // Build metadata for the new resource + var metadata *common.MetadataMutable + if resource.GetMetadata() != nil && len(resource.GetMetadata().GetLabels()) > 0 { + metadata = &common.MetadataMutable{ + Labels: resource.GetMetadata().GetLabels(), + } + } + + // Step 1: Create new resource under target namespace (without values — we create them individually) + newResource, err := h.CreateRegisteredResource(ctx, plan.TargetNamespace, resource.GetName(), nil, metadata) + if err != nil { + return fmt.Errorf("failed to create resource under namespace %s: %w", plan.TargetNamespace, err) + } + + // Step 2: Create each value individually, preserving action-attribute mappings + for _, oldValue := range plan.Values { + oldAAVs := oldValue.GetActionAttributeValues() + var aavRequests []*registeredresources.ActionAttributeValue + if len(oldAAVs) > 0 { + aavRequests = convertActionAttributeValues(oldAAVs) + } + + var valueMetadata *common.MetadataMutable + if oldValue.GetMetadata() != nil && len(oldValue.GetMetadata().GetLabels()) > 0 { + valueMetadata = &common.MetadataMutable{ + Labels: oldValue.GetMetadata().GetLabels(), + } + } + + _, err := h.CreateRegisteredResourceValue(ctx, newResource.GetId(), oldValue.GetValue(), aavRequests, valueMetadata) + if err != nil { + return fmt.Errorf("failed to create value %s for resource %s: %w", oldValue.GetValue(), newResource.GetId(), err) + } + } + + // Step 3: Delete old resource (cascades to its values) + if err := h.DeleteRegisteredResource(ctx, resource.GetId()); err != nil { + return fmt.Errorf("failed to delete old resource %s (new resource %s was created successfully - manual cleanup may be needed): %w", + resource.GetId(), newResource.GetId(), err) + } + + return nil +} + +// convertActionAttributeValues converts from policy object AAVs to request AAVs. +func convertActionAttributeValues(aavs []*policy.RegisteredResourceValue_ActionAttributeValue) []*registeredresources.ActionAttributeValue { + result := make([]*registeredresources.ActionAttributeValue, 0, len(aavs)) + for _, aav := range aavs { + req := ®isteredresources.ActionAttributeValue{} + + // Use action ID if available + if action := aav.GetAction(); action != nil && action.GetId() != "" { + req.ActionIdentifier = ®isteredresources.ActionAttributeValue_ActionId{ + ActionId: action.GetId(), + } + } + + // Use attribute value ID if available + if attrValue := aav.GetAttributeValue(); attrValue != nil && attrValue.GetId() != "" { + req.AttributeValueIdentifier = ®isteredresources.ActionAttributeValue_AttributeValueId{ + AttributeValueId: attrValue.GetId(), + } + } + + result = append(result, req) + } + return result +} diff --git a/otdfctl/migrations/registered-resources_test.go b/otdfctl/migrations/registered-resources_test.go new file mode 100644 index 0000000000..bd6725938c --- /dev/null +++ b/otdfctl/migrations/registered-resources_test.go @@ -0,0 +1,983 @@ +package migrations + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/charmbracelet/huh" + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/namespaces" + "github.com/opentdf/platform/protocol/go/policy/registeredresources" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// MockMigrationHandler implements MigrationHandler for testing. +type MockMigrationHandler struct { + Resources []*policy.RegisteredResource + ResourceValues map[string][]*policy.RegisteredResourceValue // keyed by resource ID + Namespaces []*policy.Namespace + + // Track calls + CreatedResources []createdResourceCall + CreatedResourceValues []createdResourceValueCall + DeletedResourceIDs []string + + // Control behavior + CreateResourceErr error + CreateResourceValueErr error + DeleteResourceErr error +} + +type createdResourceCall struct { + Namespace string + Name string + Values []string + Metadata *common.MetadataMutable +} + +type createdResourceValueCall struct { + ResourceID string + Value string + ActionAttributeVals []*registeredresources.ActionAttributeValue + Metadata *common.MetadataMutable +} + +func (m *MockMigrationHandler) ListRegisteredResources(_ context.Context, limit, offset int32, _ string) (*registeredresources.ListRegisteredResourcesResponse, error) { + start := int(offset) + if start >= len(m.Resources) { + return ®isteredresources.ListRegisteredResourcesResponse{}, nil + } + end := start + int(limit) + if end > len(m.Resources) { + end = len(m.Resources) + } + return ®isteredresources.ListRegisteredResourcesResponse{ + Resources: m.Resources[start:end], + }, nil +} + +func (m *MockMigrationHandler) ListRegisteredResourceValues(_ context.Context, resourceID string, limit, offset int32) (*registeredresources.ListRegisteredResourceValuesResponse, error) { + values := m.ResourceValues[resourceID] + start := int(offset) + if start >= len(values) { + return ®isteredresources.ListRegisteredResourceValuesResponse{}, nil + } + end := start + int(limit) + if end > len(values) { + end = len(values) + } + return ®isteredresources.ListRegisteredResourceValuesResponse{ + Values: values[start:end], + }, nil +} + +func (m *MockMigrationHandler) CreateRegisteredResource(_ context.Context, namespace, name string, values []string, metadata *common.MetadataMutable) (*policy.RegisteredResource, error) { + m.CreatedResources = append(m.CreatedResources, createdResourceCall{ + Namespace: namespace, + Name: name, + Values: values, + Metadata: metadata, + }) + if m.CreateResourceErr != nil { + return nil, m.CreateResourceErr + } + + // Build response with values + rrValues := make([]*policy.RegisteredResourceValue, 0, len(values)) + for i, v := range values { + rrValues = append(rrValues, &policy.RegisteredResourceValue{ + Id: fmt.Sprintf("new-value-%d", i), + Value: v, + }) + } + + return &policy.RegisteredResource{ + Id: "new-resource-id", + Name: name, + Values: rrValues, + Namespace: &policy.Namespace{ + Id: "ns-id", + Fqn: namespace, + }, + }, nil +} + +func (m *MockMigrationHandler) CreateRegisteredResourceValue(_ context.Context, resourceID string, value string, actionAttributeValues []*registeredresources.ActionAttributeValue, metadata *common.MetadataMutable) (*policy.RegisteredResourceValue, error) { + m.CreatedResourceValues = append(m.CreatedResourceValues, createdResourceValueCall{ + ResourceID: resourceID, + Value: value, + ActionAttributeVals: actionAttributeValues, + Metadata: metadata, + }) + if m.CreateResourceValueErr != nil { + return nil, m.CreateResourceValueErr + } + return &policy.RegisteredResourceValue{ + Id: "new-recreated-value-id", + Value: value, + }, nil +} + +func (m *MockMigrationHandler) DeleteRegisteredResource(_ context.Context, id string) error { + m.DeletedResourceIDs = append(m.DeletedResourceIDs, id) + if m.DeleteResourceErr != nil { + return m.DeleteResourceErr + } + return nil +} + +func (m *MockMigrationHandler) ListNamespaces(_ context.Context, _ common.ActiveStateEnum, limit, offset int32) (*namespaces.ListNamespacesResponse, error) { + start := int(offset) + if start >= len(m.Namespaces) { + return &namespaces.ListNamespacesResponse{}, nil + } + end := start + int(limit) + if end > len(m.Namespaces) { + end = len(m.Namespaces) + } + return &namespaces.ListNamespacesResponse{ + Namespaces: m.Namespaces[start:end], + }, nil +} + +// MockMigrationPrompter implements MigrationPrompter for testing. +type MockMigrationPrompter struct { + ConfirmBackupResponse bool + ConfirmBackupErr error + + BatchNamespaceResponse string + BatchNamespaceErr error + + // ResourceNamespaceResponses are returned in order, one per call to SelectResourceNamespace. + ResourceNamespaceResponses []string + ResourceNamespaceErrs []error + resourceNamespaceCallIndex int + + // ConfirmResourceNamespaceResponses are returned in order, one per call. + ConfirmResourceNamespaceResponses []string + ConfirmResourceNamespaceErrs []error + confirmResourceNamespaceCallIndex int +} + +func (m *MockMigrationPrompter) ConfirmBackup() (bool, error) { + return m.ConfirmBackupResponse, m.ConfirmBackupErr +} + +func (m *MockMigrationPrompter) SelectBatchNamespace(_ []*policy.Namespace) (string, error) { + return m.BatchNamespaceResponse, m.BatchNamespaceErr +} + +func (m *MockMigrationPrompter) SelectResourceNamespace(_ string, _ []*policy.Namespace) (string, error) { + i := m.resourceNamespaceCallIndex + m.resourceNamespaceCallIndex++ + + var err error + if i < len(m.ResourceNamespaceErrs) { + err = m.ResourceNamespaceErrs[i] + } + if err != nil { + return "", err + } + + if i < len(m.ResourceNamespaceResponses) { + return m.ResourceNamespaceResponses[i], nil + } + return "", errors.New("no more mock responses configured") +} + +func (m *MockMigrationPrompter) ConfirmResourceNamespace(_ string, _ string) (string, error) { + i := m.confirmResourceNamespaceCallIndex + m.confirmResourceNamespaceCallIndex++ + + var err error + if i < len(m.ConfirmResourceNamespaceErrs) { + err = m.ConfirmResourceNamespaceErrs[i] + } + if err != nil { + return "", err + } + + if i < len(m.ConfirmResourceNamespaceResponses) { + return m.ConfirmResourceNamespaceResponses[i], nil + } + return "", errors.New("no more mock ConfirmResourceNamespace responses configured") +} + +// Helper to build an AAV with a known namespace via the Attribute chain. +func aavWithNamespace(nsFQN string) *policy.RegisteredResourceValue_ActionAttributeValue { + return &policy.RegisteredResourceValue_ActionAttributeValue{ + Action: &policy.Action{Id: "action-1"}, + AttributeValue: &policy.Value{ + Id: "av-1", + Attribute: &policy.Attribute{ + Namespace: &policy.Namespace{Fqn: nsFQN}, + }, + }, + } +} + +// Helper to build an AAV with namespace derivable only from Value FQN. +func aavWithFQNOnly(valueFQN string) *policy.RegisteredResourceValue_ActionAttributeValue { + return &policy.RegisteredResourceValue_ActionAttributeValue{ + Action: &policy.Action{Id: "action-1"}, + AttributeValue: &policy.Value{Id: "av-1", Fqn: valueFQN}, + } +} + +func TestExtractNamespaceFQNFromValue(t *testing.T) { + t.Run("extracts from full Attribute->Namespace chain", func(t *testing.T) { + val := &policy.Value{ + Attribute: &policy.Attribute{ + Namespace: &policy.Namespace{Fqn: "https://example.com"}, + }, + } + assert.Equal(t, "https://example.com", extractNamespaceFQNFromValue(val)) + }) + + t.Run("extracts from Value FQN when Attribute is nil", func(t *testing.T) { + val := &policy.Value{Fqn: "https://example.com/attr/color/value/red"} + assert.Equal(t, "https://example.com", extractNamespaceFQNFromValue(val)) + }) + + t.Run("returns empty when both are nil", func(t *testing.T) { + val := &policy.Value{Id: "some-id"} + assert.Empty(t, extractNamespaceFQNFromValue(val)) + }) + + t.Run("returns empty when FQN has no /attr/ segment", func(t *testing.T) { + val := &policy.Value{Fqn: "https://example.com/something/else"} + assert.Empty(t, extractNamespaceFQNFromValue(val)) + }) + + t.Run("prefers Attribute chain over FQN", func(t *testing.T) { + val := &policy.Value{ + Fqn: "https://other.com/attr/color/value/red", + Attribute: &policy.Attribute{ + Namespace: &policy.Namespace{Fqn: "https://example.com"}, + }, + } + assert.Equal(t, "https://example.com", extractNamespaceFQNFromValue(val)) + }) +} + +func TestDetectRequiredNamespace(t *testing.T) { + t.Run("returns Deterministic when all AAVs share one namespace", func(t *testing.T) { + plan := RegisteredResourceMigrationPlan{ + Resource: &policy.RegisteredResource{Id: "r1"}, + Values: []*policy.RegisteredResourceValue{ + {Id: "v1", ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + aavWithNamespace("https://example.com"), + }}, + {Id: "v2", ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + aavWithNamespace("https://example.com"), + }}, + }, + } + result := detectRequiredNamespace(plan) + assert.Equal(t, "https://example.com", result.Deterministic) + assert.False(t, result.NoAAVs) + assert.False(t, result.Undetermined) + assert.Empty(t, result.Conflicting) + }) + + t.Run("returns NoAAVs when resource has no action-attribute values", func(t *testing.T) { + plan := RegisteredResourceMigrationPlan{ + Resource: &policy.RegisteredResource{Id: "r1"}, + Values: []*policy.RegisteredResourceValue{{Id: "v1"}}, + } + result := detectRequiredNamespace(plan) + assert.True(t, result.NoAAVs) + }) + + t.Run("returns NoAAVs when resource has no values", func(t *testing.T) { + plan := RegisteredResourceMigrationPlan{ + Resource: &policy.RegisteredResource{Id: "r1"}, + } + result := detectRequiredNamespace(plan) + assert.True(t, result.NoAAVs) + }) + + t.Run("returns Conflicting when AAVs reference multiple namespaces", func(t *testing.T) { + plan := RegisteredResourceMigrationPlan{ + Resource: &policy.RegisteredResource{Id: "r1"}, + Values: []*policy.RegisteredResourceValue{ + {Id: "v1", ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + aavWithNamespace("https://ns1.com"), + aavWithNamespace("https://ns2.com"), + }}, + }, + } + result := detectRequiredNamespace(plan) + assert.Len(t, result.Conflicting, 2) + assert.Contains(t, result.Conflicting, "https://ns1.com") + assert.Contains(t, result.Conflicting, "https://ns2.com") + }) + + t.Run("returns Undetermined when AAVs have nil attribute value namespace", func(t *testing.T) { + plan := RegisteredResourceMigrationPlan{ + Resource: &policy.RegisteredResource{Id: "r1"}, + Values: []*policy.RegisteredResourceValue{ + {Id: "v1", ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + {Action: &policy.Action{Id: "a1"}, AttributeValue: &policy.Value{Id: "av1"}}, + }}, + }, + } + result := detectRequiredNamespace(plan) + assert.True(t, result.Undetermined) + }) + + t.Run("falls back to Value FQN parsing", func(t *testing.T) { + plan := RegisteredResourceMigrationPlan{ + Resource: &policy.RegisteredResource{Id: "r1"}, + Values: []*policy.RegisteredResourceValue{ + {Id: "v1", ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + aavWithFQNOnly("https://example.com/attr/color/value/red"), + }}, + }, + } + result := detectRequiredNamespace(plan) + assert.Equal(t, "https://example.com", result.Deterministic) + }) +} + +func TestFilterNamespacesByFQN(t *testing.T) { + nsList := []*policy.Namespace{ + {Id: "ns-1", Fqn: "https://ns1.com"}, + {Id: "ns-2", Fqn: "https://ns2.com"}, + {Id: "ns-3", Fqn: "https://ns3.com"}, + } + + t.Run("filters to matching FQNs", func(t *testing.T) { + filtered := filterNamespacesByFQN(nsList, []string{"https://ns1.com", "https://ns3.com"}) + assert.Len(t, filtered, 2) + assert.Equal(t, "ns-1", filtered[0].GetId()) + assert.Equal(t, "ns-3", filtered[1].GetId()) + }) + + t.Run("returns empty when no matches", func(t *testing.T) { + filtered := filterNamespacesByFQN(nsList, []string{"https://other.com"}) + assert.Empty(t, filtered) + }) + + t.Run("returns empty for empty inputs", func(t *testing.T) { + assert.Empty(t, filterNamespacesByFQN(nil, nil)) + }) +} + +func TestRunBatchRegisteredResourceMigration(t *testing.T) { + styles := initMigrationDisplayStyles() + nsList := []*policy.Namespace{{Id: "ns-1", Fqn: "https://example.com"}} + + t.Run("batch prompts for no-AAV resources", func(t *testing.T) { + handler := &MockMigrationHandler{} + prompter := &MockMigrationPrompter{ + BatchNamespaceResponse: "https://example.com", + } + plan := []RegisteredResourceMigrationPlan{ + {Resource: &policy.RegisteredResource{Id: "r1", Name: "res1"}}, + {Resource: &policy.RegisteredResource{Id: "r2", Name: "res2"}}, + } + + err := runBatchRegisteredResourceMigration(context.Background(), handler, prompter, styles, plan, nsList) + require.NoError(t, err) + assert.Len(t, handler.CreatedResources, 2) + assert.Equal(t, "https://example.com", handler.CreatedResources[0].Namespace) + assert.Equal(t, "https://example.com", handler.CreatedResources[1].Namespace) + assert.Len(t, handler.DeletedResourceIDs, 2) + }) + + t.Run("auto-assigns deterministic namespaces", func(t *testing.T) { + handler := &MockMigrationHandler{} + prompter := &MockMigrationPrompter{} + plan := []RegisteredResourceMigrationPlan{ + { + Resource: &policy.RegisteredResource{Id: "r1", Name: "res1"}, + Values: []*policy.RegisteredResourceValue{{ + Id: "v1", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{aavWithNamespace("https://example.com")}, + }}, + }, + } + + err := runBatchRegisteredResourceMigration(context.Background(), handler, prompter, styles, plan, nsList) + require.NoError(t, err) + require.Len(t, handler.CreatedResources, 1) + assert.Equal(t, "https://example.com", handler.CreatedResources[0].Namespace) + }) + + t.Run("mixed: auto-assigns deterministic and batch-prompts for no-AAV", func(t *testing.T) { + handler := &MockMigrationHandler{} + prompter := &MockMigrationPrompter{ + BatchNamespaceResponse: "https://example.com", + } + plan := []RegisteredResourceMigrationPlan{ + { + Resource: &policy.RegisteredResource{Id: "r1", Name: "res1"}, + Values: []*policy.RegisteredResourceValue{{ + Id: "v1", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{aavWithNamespace("https://example.com")}, + }}, + }, + {Resource: &policy.RegisteredResource{Id: "r2", Name: "res2"}}, + } + + err := runBatchRegisteredResourceMigration(context.Background(), handler, prompter, styles, plan, nsList) + require.NoError(t, err) + assert.Len(t, handler.CreatedResources, 2) + }) + + t.Run("returns error when user aborts batch selection", func(t *testing.T) { + handler := &MockMigrationHandler{} + prompter := &MockMigrationPrompter{ + BatchNamespaceErr: errors.New("migration aborted by user"), + } + plan := []RegisteredResourceMigrationPlan{ + {Resource: &policy.RegisteredResource{Id: "r1", Name: "res1"}}, + } + + err := runBatchRegisteredResourceMigration(context.Background(), handler, prompter, styles, plan, nsList) + require.Error(t, err) + assert.Contains(t, err.Error(), "aborted") + assert.Empty(t, handler.CreatedResources) + }) + + t.Run("reports partial failure", func(t *testing.T) { + handler := &MockMigrationHandler{ + CreateResourceErr: errors.New("create failed"), + } + prompter := &MockMigrationPrompter{ + BatchNamespaceResponse: "https://example.com", + } + plan := []RegisteredResourceMigrationPlan{ + {Resource: &policy.RegisteredResource{Id: "r1", Name: "res1"}}, + {Resource: &policy.RegisteredResource{Id: "r2", Name: "res2"}}, + } + + err := runBatchRegisteredResourceMigration(context.Background(), handler, prompter, styles, plan, nsList) + require.Error(t, err) + assert.Contains(t, err.Error(), "2 of 2 resources failed") + }) +} + +func TestRunInteractiveRegisteredResourceMigration(t *testing.T) { + styles := initMigrationDisplayStyles() + nsList := []*policy.Namespace{ + {Id: "ns-1", Fqn: "https://ns1.com"}, + {Id: "ns-2", Fqn: "https://ns2.com"}, + {Id: "ns-3", Fqn: "https://ns3.com"}, + } + + // buildNoAAVPlan creates resources with no AAVs (free namespace selection). + buildNoAAVPlan := func(n int) []RegisteredResourceMigrationPlan { + plan := make([]RegisteredResourceMigrationPlan, n) + for i := range n { + plan[i] = RegisteredResourceMigrationPlan{ + Resource: &policy.RegisteredResource{ + Id: fmt.Sprintf("r%d", i+1), + Name: fmt.Sprintf("res%d", i+1), + }, + } + } + return plan + } + + t.Run("no-AAV resources use free namespace selection", func(t *testing.T) { + handler := &MockMigrationHandler{} + prompter := &MockMigrationPrompter{ + ResourceNamespaceResponses: []string{"https://ns1.com", "https://ns2.com", "https://ns3.com"}, + } + + err := runInteractiveRegisteredResourceMigration(context.Background(), handler, prompter, styles, buildNoAAVPlan(3), nsList) + require.NoError(t, err) + require.Len(t, handler.CreatedResources, 3) + assert.Equal(t, "https://ns1.com", handler.CreatedResources[0].Namespace) + assert.Equal(t, "https://ns2.com", handler.CreatedResources[1].Namespace) + assert.Equal(t, "https://ns3.com", handler.CreatedResources[2].Namespace) + assert.Len(t, handler.DeletedResourceIDs, 3) + }) + + t.Run("auto-detected namespace confirmed", func(t *testing.T) { + handler := &MockMigrationHandler{} + prompter := &MockMigrationPrompter{ + ConfirmResourceNamespaceResponses: []string{"https://ns1.com"}, + } + plan := []RegisteredResourceMigrationPlan{{ + Resource: &policy.RegisteredResource{Id: "r1", Name: "res1"}, + Values: []*policy.RegisteredResourceValue{{ + Id: "v1", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{aavWithNamespace("https://ns1.com")}, + }}, + }} + + err := runInteractiveRegisteredResourceMigration(context.Background(), handler, prompter, styles, plan, nsList) + require.NoError(t, err) + require.Len(t, handler.CreatedResources, 1) + assert.Equal(t, "https://ns1.com", handler.CreatedResources[0].Namespace) + }) + + t.Run("user skips auto-detected namespace", func(t *testing.T) { + handler := &MockMigrationHandler{} + prompter := &MockMigrationPrompter{ + ConfirmResourceNamespaceResponses: []string{optSkipResource}, + } + plan := []RegisteredResourceMigrationPlan{{ + Resource: &policy.RegisteredResource{Id: "r1", Name: "res1"}, + Values: []*policy.RegisteredResourceValue{{ + Id: "v1", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{aavWithNamespace("https://ns1.com")}, + }}, + }} + + err := runInteractiveRegisteredResourceMigration(context.Background(), handler, prompter, styles, plan, nsList) + require.NoError(t, err) + assert.Empty(t, handler.CreatedResources) + }) + + t.Run("conflict shows filtered namespace selection", func(t *testing.T) { + handler := &MockMigrationHandler{} + prompter := &MockMigrationPrompter{ + ResourceNamespaceResponses: []string{"https://ns1.com"}, + } + plan := []RegisteredResourceMigrationPlan{{ + Resource: &policy.RegisteredResource{Id: "r1", Name: "res1"}, + Values: []*policy.RegisteredResourceValue{{ + Id: "v1", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + aavWithNamespace("https://ns1.com"), + aavWithNamespace("https://ns2.com"), + }, + }}, + }} + + err := runInteractiveRegisteredResourceMigration(context.Background(), handler, prompter, styles, plan, nsList) + require.NoError(t, err) + require.Len(t, handler.CreatedResources, 1) + assert.Equal(t, "https://ns1.com", handler.CreatedResources[0].Namespace) + }) + + t.Run("undetermined namespace falls back to full selection", func(t *testing.T) { + handler := &MockMigrationHandler{} + prompter := &MockMigrationPrompter{ + ResourceNamespaceResponses: []string{"https://ns2.com"}, + } + plan := []RegisteredResourceMigrationPlan{{ + Resource: &policy.RegisteredResource{Id: "r1", Name: "res1"}, + Values: []*policy.RegisteredResourceValue{{ + Id: "v1", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + {Action: &policy.Action{Id: "a1"}, AttributeValue: &policy.Value{Id: "av1"}}, + }, + }}, + }} + + err := runInteractiveRegisteredResourceMigration(context.Background(), handler, prompter, styles, plan, nsList) + require.NoError(t, err) + require.Len(t, handler.CreatedResources, 1) + assert.Equal(t, "https://ns2.com", handler.CreatedResources[0].Namespace) + }) + + t.Run("skips resources when skip is selected", func(t *testing.T) { + handler := &MockMigrationHandler{} + prompter := &MockMigrationPrompter{ + ResourceNamespaceResponses: []string{"https://ns1.com", optSkipResource, "https://ns3.com"}, + } + + err := runInteractiveRegisteredResourceMigration(context.Background(), handler, prompter, styles, buildNoAAVPlan(3), nsList) + require.NoError(t, err) + assert.Len(t, handler.CreatedResources, 2) + assert.Equal(t, "https://ns1.com", handler.CreatedResources[0].Namespace) + assert.Equal(t, "https://ns3.com", handler.CreatedResources[1].Namespace) + }) + + t.Run("aborts migration when abort is selected", func(t *testing.T) { + handler := &MockMigrationHandler{} + prompter := &MockMigrationPrompter{ + ResourceNamespaceResponses: []string{"https://ns1.com", optAbortAll}, + } + + err := runInteractiveRegisteredResourceMigration(context.Background(), handler, prompter, styles, buildNoAAVPlan(3), nsList) + require.Error(t, err) + assert.Contains(t, err.Error(), "aborted") + assert.Len(t, handler.CreatedResources, 1) + }) + + t.Run("aborts on huh.ErrUserAborted", func(t *testing.T) { + handler := &MockMigrationHandler{} + prompter := &MockMigrationPrompter{ + ResourceNamespaceResponses: []string{"https://ns1.com"}, + ResourceNamespaceErrs: []error{nil, huh.ErrUserAborted}, + } + + err := runInteractiveRegisteredResourceMigration(context.Background(), handler, prompter, styles, buildNoAAVPlan(3), nsList) + require.Error(t, err) + assert.Contains(t, err.Error(), "aborted") + assert.Len(t, handler.CreatedResources, 1) + }) + + t.Run("skips resource on prompt error and continues", func(t *testing.T) { + handler := &MockMigrationHandler{} + prompter := &MockMigrationPrompter{ + ResourceNamespaceResponses: []string{"https://ns1.com", "", "https://ns3.com"}, + ResourceNamespaceErrs: []error{nil, errors.New("terminal glitch"), nil}, + } + + err := runInteractiveRegisteredResourceMigration(context.Background(), handler, prompter, styles, buildNoAAVPlan(3), nsList) + require.NoError(t, err) + assert.Len(t, handler.CreatedResources, 2) + }) +} + +func TestMigrateRegisteredResources(t *testing.T) { + t.Run("interactive commit - full flow with auto-detected namespace", func(t *testing.T) { + handler := &MockMigrationHandler{ + Resources: []*policy.RegisteredResource{{Id: "r1", Name: "res1"}}, + ResourceValues: map[string][]*policy.RegisteredResourceValue{ + "r1": {{ + Id: "v1", Value: "val1", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + aavWithNamespace("https://example.com"), + }, + }}, + }, + Namespaces: []*policy.Namespace{{Id: "ns-1", Fqn: "https://example.com"}}, + } + prompter := &MockMigrationPrompter{ + ConfirmBackupResponse: true, + ConfirmResourceNamespaceResponses: []string{"https://example.com"}, + } + + err := MigrateRegisteredResources(context.Background(), handler, prompter, true, true) + require.NoError(t, err) + assert.Len(t, handler.CreatedResources, 1) + assert.Equal(t, "https://example.com", handler.CreatedResources[0].Namespace) + assert.Len(t, handler.DeletedResourceIDs, 1) + }) + + t.Run("interactive commit - no-AAV resource uses free selection", func(t *testing.T) { + handler := &MockMigrationHandler{ + Resources: []*policy.RegisteredResource{{Id: "r1", Name: "res1"}}, + Namespaces: []*policy.Namespace{{Id: "ns-1", Fqn: "https://example.com"}}, + } + prompter := &MockMigrationPrompter{ + ConfirmBackupResponse: true, + ResourceNamespaceResponses: []string{"https://example.com"}, + } + + err := MigrateRegisteredResources(context.Background(), handler, prompter, true, true) + require.NoError(t, err) + assert.Len(t, handler.CreatedResources, 1) + }) + + t.Run("batch commit - full flow", func(t *testing.T) { + handler := &MockMigrationHandler{ + Resources: []*policy.RegisteredResource{{Id: "r1", Name: "res1"}}, + Namespaces: []*policy.Namespace{{Id: "ns-1", Fqn: "https://example.com"}}, + } + prompter := &MockMigrationPrompter{ + ConfirmBackupResponse: true, + BatchNamespaceResponse: "https://example.com", + } + + err := MigrateRegisteredResources(context.Background(), handler, prompter, true, false) + require.NoError(t, err) + assert.Len(t, handler.CreatedResources, 1) + }) + + t.Run("backup not confirmed - returns error", func(t *testing.T) { + handler := &MockMigrationHandler{ + Resources: []*policy.RegisteredResource{{Id: "r1", Name: "res1"}}, + Namespaces: []*policy.Namespace{{Id: "ns-1", Fqn: "https://example.com"}}, + } + prompter := &MockMigrationPrompter{ + ConfirmBackupResponse: false, + } + + err := MigrateRegisteredResources(context.Background(), handler, prompter, true, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "did not confirm backup") + assert.Empty(t, handler.CreatedResources) + }) + + t.Run("backup aborted - returns error", func(t *testing.T) { + handler := &MockMigrationHandler{ + Resources: []*policy.RegisteredResource{{Id: "r1", Name: "res1"}}, + Namespaces: []*policy.Namespace{{Id: "ns-1", Fqn: "https://example.com"}}, + } + prompter := &MockMigrationPrompter{ + ConfirmBackupErr: errors.New("user aborted backup form"), + } + + err := MigrateRegisteredResources(context.Background(), handler, prompter, true, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "aborted") + assert.Empty(t, handler.CreatedResources) + }) + + t.Run("preview mode does not prompt", func(t *testing.T) { + handler := &MockMigrationHandler{ + Resources: []*policy.RegisteredResource{{Id: "r1", Name: "res1"}}, + Namespaces: []*policy.Namespace{{Id: "ns-1", Fqn: "https://example.com"}}, + } + // Prompter has no responses configured - would error if called + prompter := &MockMigrationPrompter{} + + err := MigrateRegisteredResources(context.Background(), handler, prompter, false, false) + require.NoError(t, err) + assert.Empty(t, handler.CreatedResources) + }) + + t.Run("no resources - returns early", func(t *testing.T) { + handler := &MockMigrationHandler{} + prompter := &MockMigrationPrompter{} + + err := MigrateRegisteredResources(context.Background(), handler, prompter, true, true) + require.NoError(t, err) + }) + + t.Run("no namespaces - returns error", func(t *testing.T) { + handler := &MockMigrationHandler{ + Resources: []*policy.RegisteredResource{{Id: "r1", Name: "res1"}}, + } + prompter := &MockMigrationPrompter{} + + err := MigrateRegisteredResources(context.Background(), handler, prompter, true, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "no namespaces available") + }) +} + +func TestBuildRegisteredResourcePlan(t *testing.T) { + t.Run("builds plan with resources lacking namespaces", func(t *testing.T) { + mock := &MockMigrationHandler{ + Resources: []*policy.RegisteredResource{ + {Id: "res-1", Name: "resource-one"}, + {Id: "res-2", Name: "resource-two", Namespace: &policy.Namespace{Id: "ns-1", Fqn: "https://example.com"}}, + {Id: "res-3", Name: "resource-three"}, + }, + ResourceValues: map[string][]*policy.RegisteredResourceValue{ + "res-1": { + {Id: "val-1", Value: "value-one"}, + {Id: "val-2", Value: "value-two"}, + }, + "res-3": { + {Id: "val-3", Value: "value-three"}, + }, + }, + } + + plan, err := buildRegisteredResourcePlan(context.Background(), mock) + require.NoError(t, err) + + // Should only include resources without namespaces (res-1 and res-3) + assert.Len(t, plan, 2) + assert.Equal(t, "res-1", plan[0].Resource.GetId()) + assert.Equal(t, "resource-one", plan[0].Resource.GetName()) + assert.Len(t, plan[0].Values, 2) + assert.Equal(t, "res-3", plan[1].Resource.GetId()) + assert.Len(t, plan[1].Values, 1) + }) + + t.Run("returns empty plan when no resources exist", func(t *testing.T) { + mock := &MockMigrationHandler{} + + plan, err := buildRegisteredResourcePlan(context.Background(), mock) + require.NoError(t, err) + assert.Empty(t, plan) + }) + + t.Run("returns empty plan when all resources have namespaces", func(t *testing.T) { + mock := &MockMigrationHandler{ + Resources: []*policy.RegisteredResource{ + {Id: "res-1", Name: "resource-one", Namespace: &policy.Namespace{Id: "ns-1"}}, + {Id: "res-2", Name: "resource-two", Namespace: &policy.Namespace{Id: "ns-2"}}, + }, + } + + plan, err := buildRegisteredResourcePlan(context.Background(), mock) + require.NoError(t, err) + assert.Empty(t, plan) + }) +} + +func TestCommitRegisteredResourceMigration(t *testing.T) { + t.Run("creates resource with correct namespace and name", func(t *testing.T) { + mock := &MockMigrationHandler{} + + plan := RegisteredResourceMigrationPlan{ + Resource: &policy.RegisteredResource{ + Id: "old-id", + Name: "my-resource", + Metadata: &common.Metadata{ + Labels: map[string]string{"env": "prod"}, + }, + }, + Values: []*policy.RegisteredResourceValue{ + {Id: "old-val-1", Value: "val-a"}, + {Id: "old-val-2", Value: "val-b"}, + }, + TargetNamespace: "https://example.com", + Commit: true, + } + + err := commitRegisteredResourceMigration(context.Background(), mock, plan) + require.NoError(t, err) + + // Verify resource was created without values (values are created individually) + require.Len(t, mock.CreatedResources, 1) + assert.Equal(t, "https://example.com", mock.CreatedResources[0].Namespace) + assert.Equal(t, "my-resource", mock.CreatedResources[0].Name) + assert.Nil(t, mock.CreatedResources[0].Values) + assert.Equal(t, map[string]string{"env": "prod"}, mock.CreatedResources[0].Metadata.GetLabels()) + + // Verify values were created individually + require.Len(t, mock.CreatedResourceValues, 2) + assert.Equal(t, "new-resource-id", mock.CreatedResourceValues[0].ResourceID) + assert.Equal(t, "val-a", mock.CreatedResourceValues[0].Value) + assert.Equal(t, "val-b", mock.CreatedResourceValues[1].Value) + + // Verify old resource was deleted + assert.Contains(t, mock.DeletedResourceIDs, "old-id") + }) + + t.Run("re-creates values with action-attribute mappings", func(t *testing.T) { + mock := &MockMigrationHandler{} + + plan := RegisteredResourceMigrationPlan{ + Resource: &policy.RegisteredResource{ + Id: "old-id", + Name: "my-resource", + }, + Values: []*policy.RegisteredResourceValue{ + { + Id: "old-val-1", + Value: "val-a", + ActionAttributeValues: []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Id: "aav-1", + Action: &policy.Action{Id: "action-1"}, + AttributeValue: &policy.Value{Id: "attr-val-1"}, + }, + }, + }, + }, + TargetNamespace: "https://example.com", + Commit: true, + } + + err := commitRegisteredResourceMigration(context.Background(), mock, plan) + require.NoError(t, err) + + // Should have re-created the value with AAVs + require.Len(t, mock.CreatedResourceValues, 1) + assert.Equal(t, "new-resource-id", mock.CreatedResourceValues[0].ResourceID) + assert.Equal(t, "val-a", mock.CreatedResourceValues[0].Value) + require.Len(t, mock.CreatedResourceValues[0].ActionAttributeVals, 1) + }) + + t.Run("returns error when create fails", func(t *testing.T) { + mock := &MockMigrationHandler{ + CreateResourceErr: errors.New("create failed"), + } + + plan := RegisteredResourceMigrationPlan{ + Resource: &policy.RegisteredResource{ + Id: "old-id", + Name: "my-resource", + }, + TargetNamespace: "https://example.com", + Commit: true, + } + + err := commitRegisteredResourceMigration(context.Background(), mock, plan) + require.Error(t, err) + assert.Contains(t, err.Error(), "create failed") + + // Old resource should NOT have been deleted + assert.Empty(t, mock.DeletedResourceIDs) + }) + + t.Run("returns error when delete fails", func(t *testing.T) { + mock := &MockMigrationHandler{ + DeleteResourceErr: errors.New("delete failed"), + } + + plan := RegisteredResourceMigrationPlan{ + Resource: &policy.RegisteredResource{ + Id: "old-id", + Name: "my-resource", + }, + Values: []*policy.RegisteredResourceValue{ + {Id: "old-val-1", Value: "val-a"}, + }, + TargetNamespace: "https://example.com", + Commit: true, + } + + err := commitRegisteredResourceMigration(context.Background(), mock, plan) + require.Error(t, err) + assert.Contains(t, err.Error(), "delete failed") + }) + + t.Run("returns error when plan not ready", func(t *testing.T) { + mock := &MockMigrationHandler{} + + plan := RegisteredResourceMigrationPlan{ + Resource: &policy.RegisteredResource{Id: "old-id"}, + Commit: false, + } + + err := commitRegisteredResourceMigration(context.Background(), mock, plan) + require.Error(t, err) + assert.Contains(t, err.Error(), "not ready for commit") + }) +} + +func TestBuildNamespaceOptions(t *testing.T) { + t.Run("builds options from namespaces with FQN", func(t *testing.T) { + nsList := []*policy.Namespace{ + {Id: "ns-1", Name: "example", Fqn: "https://example.com"}, + {Id: "ns-2", Name: "other", Fqn: "https://other.org"}, + } + + opts := buildNamespaceOptions(nsList) + assert.Len(t, opts, 2) + }) + + t.Run("returns empty options for empty namespace list", func(t *testing.T) { + opts := buildNamespaceOptions(nil) + assert.Empty(t, opts) + }) +} + +func TestConvertActionAttributeValues(t *testing.T) { + t.Run("converts action-attribute values correctly", func(t *testing.T) { + aavs := []*policy.RegisteredResourceValue_ActionAttributeValue{ + { + Id: "aav-1", + Action: &policy.Action{Id: "action-1"}, + AttributeValue: &policy.Value{Id: "attr-val-1"}, + }, + { + Id: "aav-2", + Action: &policy.Action{Id: "action-2"}, + AttributeValue: &policy.Value{Id: "attr-val-2"}, + }, + } + + result := convertActionAttributeValues(aavs) + require.Len(t, result, 2) + assert.Equal(t, "action-1", result[0].GetActionId()) + assert.Equal(t, "attr-val-1", result[0].GetAttributeValueId()) + assert.Equal(t, "action-2", result[1].GetActionId()) + assert.Equal(t, "attr-val-2", result[1].GetAttributeValueId()) + }) + + t.Run("handles empty input", func(t *testing.T) { + result := convertActionAttributeValues(nil) + assert.Empty(t, result) + }) +} diff --git a/otdfctl/migrations/styles.go b/otdfctl/migrations/styles.go new file mode 100644 index 0000000000..06b605ce46 --- /dev/null +++ b/otdfctl/migrations/styles.go @@ -0,0 +1,35 @@ +package migrations + +import "github.com/charmbracelet/lipgloss" + +// migrationDisplayStyles holds all lipgloss styles for migration output. +type migrationDisplayStyles struct { + styleTitle lipgloss.Style + styleResourceID lipgloss.Style + styleNamespace lipgloss.Style + styleName lipgloss.Style + styleValue lipgloss.Style + styleID lipgloss.Style + styleWarning lipgloss.Style + styleInfo lipgloss.Style + styleSeparator lipgloss.Style + styleAction lipgloss.Style + separatorText string +} + +// initMigrationDisplayStyles initializes and returns a migrationDisplayStyles struct. +func initMigrationDisplayStyles() *migrationDisplayStyles { + return &migrationDisplayStyles{ + styleTitle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")), + styleResourceID: lipgloss.NewStyle().Foreground(lipgloss.Color("10")), + styleNamespace: lipgloss.NewStyle().Foreground(lipgloss.Color("11")), + styleName: lipgloss.NewStyle().Foreground(lipgloss.Color("13")), + styleValue: lipgloss.NewStyle().Foreground(lipgloss.Color("14")), + styleID: lipgloss.NewStyle().Foreground(lipgloss.Color("15")), + styleWarning: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")), + styleInfo: lipgloss.NewStyle(), + styleSeparator: lipgloss.NewStyle().Faint(true), + styleAction: lipgloss.NewStyle().Foreground(lipgloss.Color("6")), + separatorText: "----------------------------------------------------------------------------------------------------", + } +} diff --git a/otdfctl/pkg/auth/auth.go b/otdfctl/pkg/auth/auth.go new file mode 100644 index 0000000000..bd505d88ab --- /dev/null +++ b/otdfctl/pkg/auth/auth.go @@ -0,0 +1,372 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "net" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/go-jose/go-jose/v3/jwt" + "github.com/google/uuid" + "github.com/opentdf/platform/otdfctl/pkg/profiles" + "github.com/opentdf/platform/otdfctl/pkg/utils" + "github.com/opentdf/platform/sdk" + oidcrp "github.com/zitadel/oidc/v3/pkg/client/rp" + oidcCLI "github.com/zitadel/oidc/v3/pkg/client/rp/cli" + httphelper "github.com/zitadel/oidc/v3/pkg/http" + "github.com/zitadel/oidc/v3/pkg/oidc" + "golang.org/x/oauth2" +) + +const authCallbackPath = "/callback" + +type ClientCredentials struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + Scopes []string `json:"scopes,omitempty"` +} + +type platformConfiguration struct { + issuer string + authzEndpoint string + tokenEndpoint string +} + +type oidcClientCredentials struct { + clientID string + clientSecret string + isPublic bool +} + +type JWTClaims struct { + Expiration int64 `json:"exp"` +} + +func NormalizeScopes(scopes []string) []string { + if len(scopes) == 0 { + return nil + } + normalized := make([]string, 0, len(scopes)) + for _, scope := range scopes { + normalized = append(normalized, strings.Fields(scope)...) + } + if len(normalized) == 0 { + return nil + } + return normalized +} + +func normalizeClientCredScopes(creds *ClientCredentials) { + if creds == nil { + return + } + creds.Scopes = NormalizeScopes(creds.Scopes) +} + +// Retrieves credentials by reading specified file +func GetClientCredsFromFile(filepath string) (ClientCredentials, error) { + creds := ClientCredentials{} + f, err := os.Open(filepath) + if err != nil { + return creds, errors.Join(errors.New("failed to open creds file"), err) + } + defer f.Close() + + if err := json.NewDecoder(f).Decode(&creds); err != nil { + return creds, errors.Join(errors.New("failed to decode creds file"), err) + } + normalizeClientCredScopes(&creds) + + return creds, nil +} + +// Parse the JSON and return the client ID and secret +func GetClientCredsFromJSON(credsJSON []byte) (ClientCredentials, error) { + creds := ClientCredentials{} + if err := json.Unmarshal(credsJSON, &creds); err != nil { + return creds, errors.Join(errors.New("failed to decode creds JSON"), err) + } + normalizeClientCredScopes(&creds) + + return creds, nil +} + +func getPlatformConfiguration(endpoint string, tlsNoVerify bool) (platformConfiguration, error) { + c := platformConfiguration{} + + normalized, err := utils.NormalizeEndpoint(endpoint) + if err != nil { + return c, err + } + + opts := []sdk.Option{sdk.WithConnectionValidation()} + if tlsNoVerify { + opts = append(opts, sdk.WithInsecureSkipVerifyConn()) + } + + if normalized.Scheme == "http" { + opts = append(opts, sdk.WithInsecurePlaintextConn()) + } + + s, err := sdk.New(normalized.String(), opts...) + if err != nil { + return c, err + } + + var e error + c.issuer, e = s.PlatformConfiguration.Issuer() + if e != nil { + err = errors.Join(err, sdk.ErrPlatformIssuerNotFound) + } + + c.authzEndpoint, e = s.PlatformConfiguration.AuthzEndpoint() + if e != nil { + err = errors.Join(err, sdk.ErrPlatformAuthzEndpointNotFound) + } + + c.tokenEndpoint, e = s.PlatformConfiguration.TokenEndpoint() + if e != nil { + err = errors.Join(err, sdk.ErrPlatformTokenEndpointNotFound) + } + + if err != nil { + return c, errors.Join(err, ErrProfileCredentialsNotFound) + } + + return c, nil +} + +func buildToken(c *profiles.AuthCredentials) *oauth2.Token { + return &oauth2.Token{ + AccessToken: c.AccessToken.AccessToken, + Expiry: time.Unix(c.AccessToken.Expiration, 0), + RefreshToken: c.AccessToken.RefreshToken, + } +} + +func ParseClaimsJWT(accessToken string) (JWTClaims, error) { + c := JWTClaims{} + jwt, err := jwt.ParseSigned(accessToken) + if err != nil { + return c, errors.Join(ErrParsingAccessToken, err) + } + if err := jwt.UnsafeClaimsWithoutVerification(&c); err != nil { + return c, errors.Join(ErrParsingAccessToken, err) + } + return c, nil +} + +func GetSDKAuthOptionFromProfile(profile *profiles.OtdfctlProfileStore) (sdk.Option, error) { + c := profile.GetAuthCredentials() + + switch c.AuthType { + case profiles.AuthTypeClientCredentials: + return sdk.WithClientCredentials(c.ClientID, c.ClientSecret, NormalizeScopes(c.Scopes)), nil + case profiles.AuthTypeAccessToken: + tokenSource := oauth2.StaticTokenSource(buildToken(&c)) + return sdk.WithOAuthAccessTokenSource(tokenSource), nil + default: + return nil, ErrInvalidAuthType + } +} + +func ValidateProfileAuthCredentials(ctx context.Context, profile *profiles.OtdfctlProfileStore) error { + c := profile.GetAuthCredentials() + + switch c.AuthType { + case "": + return ErrProfileCredentialsNotFound + case profiles.AuthTypeClientCredentials: + _, err := GetTokenWithClientCreds(ctx, profile.GetEndpoint(), c.ClientID, c.ClientSecret, profile.GetTLSNoVerify(), c.Scopes) + if err != nil { + return err + } + return nil + case profiles.AuthTypeAccessToken: + if !buildToken(&c).Valid() { + return ErrAccessTokenExpired + } + default: + return ErrInvalidAuthType + } + return nil +} + +func GetTokenWithProfile(ctx context.Context, profile *profiles.OtdfctlProfileStore) (*oauth2.Token, error) { + c := profile.GetAuthCredentials() + + switch c.AuthType { + case profiles.AuthTypeClientCredentials: + return GetTokenWithClientCreds(ctx, profile.GetEndpoint(), c.ClientID, c.ClientSecret, profile.GetTLSNoVerify(), c.Scopes) + case profiles.AuthTypeAccessToken: + return buildToken(&c), nil + default: + return nil, ErrInvalidAuthType + } +} + +// Uses the OAuth2 client credentials flow to obtain a token. +func GetTokenWithClientCreds(ctx context.Context, endpoint string, clientID string, clientSecret string, tlsNoVerify bool, scopes []string) (*oauth2.Token, error) { + rp, err := newOidcRelyingParty(ctx, endpoint, tlsNoVerify, oidcClientCredentials{ + clientID: clientID, + clientSecret: clientSecret, + }) + if err != nil { + return nil, err + } + params := url.Values{} + if normalized := NormalizeScopes(scopes); len(normalized) > 0 { + params.Set("scope", strings.Join(normalized, " ")) + } + return oidcrp.ClientCredentials(ctx, rp, params) +} + +const ( + keyLength = 16 + fiveSecDuration = 5 * time.Second +) + +// GetFreePort returns an available TCP port on localhost. +// The function works by asking the operating system to assign +// a free port (by using port 0), then returns that assigned port. +func GetFreePort(ctx context.Context) (int, error) { + // Create a listener on localhost with port 0 (OS will assign a free port) + cfg := &net.ListenConfig{} + listener, err := cfg.Listen(ctx, "tcp", "localhost:0") + if err != nil { + return 0, fmt.Errorf("failed to find available port: %w", err) + } + + // Make sure we release the port when done + defer listener.Close() + + // Get the address information from the listener + addr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + return 0, errors.New("failed to get TCP address from listener") + } + + // Return the port that was assigned + return addr.Port, nil +} + +// Facilitates an auth code PKCE flow to obtain OIDC tokens. +// Spawns a local server to handle the callback and opens a browser window in each respective OS. +func Login(ctx context.Context, platformEndpoint, tokenURL, authURL, publicClientID, authCodeFlowPort string) (*oauth2.Token, error) { + // Generate random hash and encryption keys for cookie handling + hashKey := make([]byte, keyLength) + encryptKey := make([]byte, keyLength) + + _, err := rand.Read(hashKey) + if err != nil { + return nil, err + } + + _, err = rand.Read(encryptKey) + if err != nil { + return nil, err + } + + if strings.TrimSpace(authCodeFlowPort) == "" { + port, err := GetFreePort(ctx) + if err != nil { + return nil, fmt.Errorf("failed to find available port for auth code flow: %w", err) + } + authCodeFlowPort = strconv.Itoa(port) + } + + conf := &oauth2.Config{ + ClientID: publicClientID, + Scopes: []string{"openid", "profile", "email"}, + RedirectURL: fmt.Sprintf("http://localhost:%s%s", authCodeFlowPort, authCallbackPath), + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + } + + cookiehandler := httphelper.NewCookieHandler(hashKey, encryptKey) + + relyingParty, err := oidcrp.NewRelyingPartyOAuth(conf, + // allow cookie handling for PKCE + oidcrp.WithCookieHandler(cookiehandler), + // use PKCE + oidcrp.WithPKCE(cookiehandler), + // allow IAT claim offset of 5 seconds + oidcrp.WithVerifierOpts(oidcrp.WithIssuedAtOffset(fiveSecDuration)), + ) + if err != nil { + return nil, fmt.Errorf("failed to create relying party: %w", err) + } + stateProvider := func() string { + return uuid.New().String() + } + tok := oidcCLI.CodeFlow[*oidc.IDTokenClaims](ctx, relyingParty, authCallbackPath, authCodeFlowPort, stateProvider) + return &oauth2.Token{ + AccessToken: tok.AccessToken, + TokenType: tok.TokenType, + RefreshToken: tok.RefreshToken, + Expiry: tok.Expiry, + }, nil +} + +// Logs in using the auth code PKCE flow driven by the platform well-known idP OIDC configuration. +func LoginWithPKCE(ctx context.Context, host, clientID string, tlsNoVerify bool, port string) (*oauth2.Token, error) { + pc, err := getPlatformConfiguration(host, tlsNoVerify) + if err != nil { + return nil, fmt.Errorf("failed to get platform configuration: %w", err) + } + + tok, err := Login(ctx, host, pc.tokenEndpoint, pc.authzEndpoint, clientID, port) + if err != nil { + return nil, fmt.Errorf("failed to login: %w", err) + } + + return tok, nil +} + +// Revokes the access token +func RevokeAccessToken(ctx context.Context, endpoint, clientID, refreshToken string, tlsNoVerify bool) error { + rp, err := newOidcRelyingParty(ctx, endpoint, tlsNoVerify, oidcClientCredentials{ + clientID: clientID, + isPublic: true, + }) + if err != nil { + return err + } + return oidcrp.RevokeToken(ctx, rp, refreshToken, "refresh_token") +} + +func newOidcRelyingParty(ctx context.Context, endpoint string, tlsNoVerify bool, clientCreds oidcClientCredentials) (oidcrp.RelyingParty, error) { + if clientCreds.clientID == "" { + return nil, errors.New("client ID is required") + } + if clientCreds.clientSecret == "" && !clientCreds.isPublic { + return nil, errors.New("client secret is required") + } + if clientCreds.clientSecret != "" && clientCreds.isPublic { + return nil, errors.New("client secret must be empty for public clients") + } + + pc, err := getPlatformConfiguration(endpoint, tlsNoVerify) + if err != nil { + return nil, err + } + + return oidcrp.NewRelyingPartyOIDC( + ctx, + pc.issuer, + clientCreds.clientID, + clientCreds.clientSecret, + "", + nil, + oidcrp.WithHTTPClient(utils.NewHTTPClient(tlsNoVerify)), + ) +} diff --git a/otdfctl/pkg/auth/errors.go b/otdfctl/pkg/auth/errors.go new file mode 100644 index 0000000000..c26dea08c2 --- /dev/null +++ b/otdfctl/pkg/auth/errors.go @@ -0,0 +1,13 @@ +package auth + +import "errors" + +var ( + ErrAccessTokenExpired = errors.New("access token expired") + ErrAccessTokenNotFound = errors.New("no access token found") + ErrClientCredentialsNotFound = errors.New("client credentials not found") + ErrInvalidAuthType = errors.New("invalid auth type") + ErrUnauthenticated = errors.New("not logged in") + ErrParsingAccessToken = errors.New("failed to parse access token") + ErrProfileCredentialsNotFound = errors.New("profile missing credentials") +) diff --git a/otdfctl/pkg/cli/cli.go b/otdfctl/pkg/cli/cli.go new file mode 100644 index 0000000000..13e64a59da --- /dev/null +++ b/otdfctl/pkg/cli/cli.go @@ -0,0 +1,55 @@ +package cli + +import ( + "context" + + "github.com/spf13/cobra" +) + +type Cli struct { + cmd *cobra.Command + args []string + + // Helpers + Flags *flagHelper + FlagHelper *flagHelper + printer *Printer +} + +// New creates a new Cli object +func New(cmd *cobra.Command, args []string, options ...cliVariadicOption) *Cli { + opts := cliOptions{ + printerJSON: false, + } + for _, opt := range options { + opts = opt(opts) + } + + cli := &Cli{ + cmd: cmd, + args: args, + } + + if cmd == nil { + ExitWithError("cli expects a command", ErrPrinterExpectsCommand) + } + + cli.Flags = newFlagHelper(cmd) + // Temp wrapper for FlagHelper until we can remove it + cli.FlagHelper = cli.Flags + + cli.printer = newPrinter(cli) + if opts.printerJSON { + cli.printer.setJSON(true) + } + + return cli +} + +func (c *Cli) Cmd() *cobra.Command { + return c.cmd +} + +func (c *Cli) Context() context.Context { + return c.cmd.Context() +} diff --git a/otdfctl/pkg/cli/clioptions.go b/otdfctl/pkg/cli/clioptions.go new file mode 100644 index 0000000000..6aaa589d7c --- /dev/null +++ b/otdfctl/pkg/cli/clioptions.go @@ -0,0 +1,15 @@ +package cli + +type cliOptions struct { + printerJSON bool +} + +type cliVariadicOption func(cliOptions) cliOptions + +// WithPrintJSON is a variadic option that enforces JSON output for the printer +func WithPrintJSON() cliVariadicOption { + return func(o cliOptions) cliOptions { + o.printerJSON = true + return o + } +} diff --git a/otdfctl/pkg/cli/confirm.go b/otdfctl/pkg/cli/confirm.go new file mode 100644 index 0000000000..36dc46048a --- /dev/null +++ b/otdfctl/pkg/cli/confirm.go @@ -0,0 +1,110 @@ +package cli + +import ( + "fmt" + + "github.com/charmbracelet/huh" +) + +const ( + // top level actions + ActionGet = "get" + ActionList = "list" + ActionCreate = "create" + ActionUpdate = "update" + ActionUpdateUnsafe = "unsafely update" + ActionDeactivate = "deactivate" + ActionReactivate = "reactivate" + ActionDelete = "delete" + + // text input names + InputNameFQN = "fully qualified name (FQN)" + InputNameFQNUpdated = "deprecated fully qualified name (FQN) being altered" +) + +func ConfirmActionSubtext(action, resource, id, subtext string, force bool) { + if force { + return + } + var confirm bool + title := fmt.Sprintf("Are you sure you want to %s %s:\n\n\t%s", action, resource, id) + if subtext != "" { + // since we don't return an error to stay consistent with the original function, + // only append the subtext if populated + title += "\n\n" + subtext + } + err := huh.NewConfirm(). + Title(title). + Affirmative("yes"). + Negative("no"). + Value(&confirm). + Run() + if err != nil { + ExitWithError("Confirmation prompt failed", err) + } + + if !confirm { + ExitWithError("Aborted", nil) + } +} + +func ConfirmAction(action, resource, id string, force bool) { + if force { + return + } + var confirm bool + err := huh.NewConfirm(). + Title(fmt.Sprintf("Are you sure you want to %s %s:\n\n\t%s", action, resource, id)). + Affirmative("yes"). + Negative("no"). + Value(&confirm). + Run() + if err != nil { + ExitWithError("Confirmation prompt failed", err) + } + + if !confirm { + ExitWithError("Aborted", nil) + } +} + +func ConfirmTextInput(action, resource, inputName, shouldMatchValue string) { + var input string + err := huh.NewInput(). + Title(fmt.Sprintf("To confirm you want to %s this %s and accept any side effects, please enter the %s to proceed: %s", action, resource, inputName, shouldMatchValue)). + Value(&input). + Validate(func(s string) error { + if s != shouldMatchValue { + return fmt.Errorf("entered FQN [%s] does not match required %s: %s", s, inputName, shouldMatchValue) + } + return nil + }).Run() + if err != nil { + ExitWithError("Confirmation prompt failed", err) + } +} + +func AskForInput(message string) string { + var input string + err := huh.NewInput(). + Value(&input). + Title(message). + Run() + if err != nil { + ExitWithError("Prompt for input failed", err) + } + return input +} + +func AskForSecret(message string) string { + var secret string + err := huh.NewInput(). + Value(&secret). + Title(message). + EchoMode(huh.EchoModePassword). + Run() + if err != nil { + ExitWithError("Prompt for secret failed", err) + } + return secret +} diff --git a/otdfctl/pkg/cli/errors.go b/otdfctl/pkg/cli/errors.go new file mode 100644 index 0000000000..61be89db94 --- /dev/null +++ b/otdfctl/pkg/cli/errors.go @@ -0,0 +1,82 @@ +package cli + +import ( + "io" + "os" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + ExitCodeSuccess = 0 + ExitCodeError = 1 +) + +func ExitWithError(errMsg string, err error) { + // This is temporary until we can refactor the code to use the Cli struct + (&Cli{printer: &Printer{enabled: true}}).ExitWithError(errMsg, err) +} + +func ExitWithNotFoundError(errMsg string, err error) { + // This is temporary until we can refactor the code to use the Cli struct + (&Cli{printer: &Printer{enabled: true}}).ExitWithNotFoundError(errMsg, err) +} + +func ExitWithWarning(warnMsg string) { + // This is temporary until we can refactor the code to use the Cli struct + (&Cli{printer: &Printer{enabled: true}}).ExitWithWarning(warnMsg) +} + +// ExitWithError prints an error message and exits with a non-zero status code. +func (c *Cli) ExitWithError(errMsg string, err error) { + c.ExitWithNotFoundError(errMsg, err) + c.ExitWith(ErrorMessage(errMsg, err), ErrorJSON(errMsg, err), ExitCodeError, os.Stderr) +} + +// ExitWithNotFoundError prints an error message and exits with a non-zero status code if the error is a NotFound error. +func (c *Cli) ExitWithNotFoundError(errMsg string, err error) { + if err != nil { + if e, ok := status.FromError(err); ok && e.Code() == codes.NotFound { + c.ExitWith( + ErrorMessage(errMsg+": not found", nil), + MessageJSON("ERROR", errMsg+": not found"), + ExitCodeError, + os.Stderr, + ) + } + } +} + +func (c *Cli) ExitWithWarning(warnMsg string) { + c.ExitWith(WarningMessage(warnMsg), WarningJSON(warnMsg), ExitCodeError, os.Stderr) +} + +func (c *Cli) ExitWithSuccess(msg string) { + c.ExitWith(SuccessMessage(msg), SuccessJSON(msg), ExitCodeSuccess, os.Stdout) +} + +func (c *Cli) ExitWithMessage(msg string, code int) { + if c.printer.enabled { + c.println(os.Stdout, msg) + os.Exit(code) + } +} + +func (c *Cli) ExitWithJSON(v interface{}, code int) { + if c.printer.json { + c.printJSON(v, os.Stdout) + os.Exit(code) + } +} + +// exitWith is the core exit function that handles both JSON and styled output +// It writes to the appropriate stream (stdout for success, stderr for errors/warnings) +func (c *Cli) ExitWith(styledMsg string, jsonMsg interface{}, code int, w io.Writer) { + if c.printer.json { + c.printJSON(jsonMsg, w) + } else { + c.println(w, styledMsg) + } + os.Exit(code) +} diff --git a/otdfctl/pkg/cli/flagValues.go b/otdfctl/pkg/cli/flagValues.go new file mode 100644 index 0000000000..5f5c5f76a9 --- /dev/null +++ b/otdfctl/pkg/cli/flagValues.go @@ -0,0 +1,150 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/opentdf/platform/protocol/go/common" + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +type FlagsStringSliceOptions struct { + Min int + Max int +} + +type flagHelper struct { + cmd *cobra.Command +} + +func newFlagHelper(cmd *cobra.Command) *flagHelper { + return &flagHelper{cmd: cmd} +} + +func (f flagHelper) GetRequiredString(flag string) string { + v := f.cmd.Flag(flag).Value.String() + if v == "" { + ExitWithError("Flag '--"+flag+"' is required", nil) + } + return v +} + +func (f flagHelper) GetRequiredID(idFlag string) string { + v := f.GetRequiredString(idFlag) + id, err := uuid.Parse(v) + if err != nil { + ExitWithError(fmt.Sprintf("Flag '--%s' received value '%s' must be a valid UUID", idFlag, v), nil) + } + return id.String() +} + +func (f flagHelper) GetOptionalID(idFlag string) string { + p := f.GetOptionalString(idFlag) + if p == "" { + return "" + } + id, err := uuid.Parse(p) + if err != nil { + ExitWithError(fmt.Sprintf("Optional flag '--%s' received value '%s' and must be a valid UUID if used", idFlag, p), nil) + } + return id.String() +} + +func (f flagHelper) GetOptionalString(flag string) string { + p := f.cmd.Flag(flag) + if p == nil { + return "" + } + return p.Value.String() +} + +func (f flagHelper) GetStringSlice(flag string, v []string, opts FlagsStringSliceOptions) []string { + if len(v) < opts.Min { + ExitWithError(fmt.Sprintf("Flag '--%s' must have at least %d non-empty values", flag, opts.Min), nil) + } + if opts.Max > 0 && len(v) > opts.Max { + ExitWithError(fmt.Sprintf("Flag '--%s' must have at most %d non-empty values", flag, opts.Max), nil) + } + return v +} + +func (f flagHelper) GetRequiredInt32(flag string) int32 { + v, e := f.cmd.Flags().GetInt32(flag) + if e != nil { + ExitWithError("Flag '--"+flag+"' is required", nil) + } + // if v == 0 { + // fmt.Println(ErrorMessage("Flag "+flag+" must be greater than 0", nil)) + // os.Exit(1) + // } + return v +} + +func (f flagHelper) GetOptionalInt32(flag string) int32 { + v, _ := f.cmd.Flags().GetInt32(flag) + return v +} + +func (f flagHelper) GetOptionalBool(flag string) bool { + v, _ := f.cmd.Flags().GetBool(flag) + return v +} + +// Returns nil when the flag is not explicitly set. +func (f flagHelper) GetOptionalBoolWrapper(flag string) *wrapperspb.BoolValue { + if !f.cmd.Flags().Changed(flag) { + return nil + } + v, _ := f.cmd.Flags().GetBool(flag) + return wrapperspb.Bool(v) +} + +func (f flagHelper) GetRequiredBool(flag string) bool { + v, e := f.cmd.Flags().GetBool(flag) + if e != nil { + ExitWithError("Flag '--"+flag+"' is required", nil) + } + return v +} + +// Transforms into enum value and defaults to active state +func GetState(cmd *cobra.Command) common.ActiveStateEnum { + state := common.ActiveStateEnum_ACTIVE_STATE_ENUM_ACTIVE + stateFlag := strings.ToUpper(cmd.Flag("state").Value.String()) + if stateFlag != "" { + switch stateFlag { + case "INACTIVE": + state = common.ActiveStateEnum_ACTIVE_STATE_ENUM_INACTIVE + case "ANY": + state = common.ActiveStateEnum_ACTIVE_STATE_ENUM_ANY + } + } + return state +} + +// func (f flagHelper) GetStructSlice(flag string, v []StructFlag[T], opts flagHelperStringSliceOptions) ([]StructFlag[T], err) { +// if len(v) < opts.Min { +// fmt.Println(ErrorMessage(fmt.Sprintf("Flag %s must have at least %d non-empty values", flag, opts.Min), nil)) +// os.Exit(1) +// } +// if opts.Max > 0 && len(v) > opts.Max { +// fmt.Println(ErrorMessage(fmt.Sprintf("Flag %s must have at most %d non-empty values", flag, opts.Max), nil)) +// os.Exit(1) +// } +// return v +// } + +// type StructFlag[T any] struct { +// Val T +// } + +// func (this StructFlag[T]) String() string { +// b, _ := json.Marshal(this) +// return string(b) +// } + +// func (this StructFlag[T]) Set(s string) error { +// return json.Unmarshal([]byte(s), this) +// } diff --git a/otdfctl/pkg/cli/messages.go b/otdfctl/pkg/cli/messages.go new file mode 100644 index 0000000000..ce2b733a0b --- /dev/null +++ b/otdfctl/pkg/cli/messages.go @@ -0,0 +1,64 @@ +package cli + +import ( + "github.com/charmbracelet/lipgloss" +) + +func SuccessMessage(msg string) string { + return lipgloss.JoinHorizontal(lipgloss.Left, styleSuccessStatusBar.Render("SUCCESS"), msg) +} + +func SuccessJSON(msg string) interface{} { + return MessageJSON("SUCCESS", msg) +} + +func FooterMessage(msg string) string { + if msg == "" { + return "" + } + w := lipgloss.Width + note := footerLabelStyle.Render("NOTE") + footer := footerTextStyle.Width(TermWidth() - w(note)).Render(msg) + return lipgloss.JoinHorizontal( + lipgloss.Left, + note, + footer, + ) +} + +func DebugMessage(msg string) string { + return lipgloss.JoinHorizontal(lipgloss.Left, styleDebugStatusBar.Render("DEBUG"), msg) +} + +func DebugJSON(msg string) interface{} { + return MessageJSON("DEBUG", msg) +} + +func ErrorMessage(msg string, err error) string { + if err != nil { + msg += ": " + err.Error() + } + return lipgloss.JoinHorizontal(lipgloss.Left, styleErrorStatusBar.Render("ERROR"), msg) +} + +func ErrorJSON(msg string, err error) interface{} { + if err != nil { + msg += ": " + err.Error() + } + return MessageJSON("ERROR", msg) +} + +func WarningMessage(msg string) string { + return lipgloss.JoinHorizontal(lipgloss.Left, styleWarningStatusBar.Render("WARNING"), msg) +} + +func WarningJSON(msg string) interface{} { + return MessageJSON("WARNING", msg) +} + +func MessageJSON(status string, msg string) interface{} { + return map[string]interface{}{ + "status": status, + "message": msg, + } +} diff --git a/otdfctl/pkg/cli/pipe.go b/otdfctl/pkg/cli/pipe.go new file mode 100644 index 0000000000..561c8ff19f --- /dev/null +++ b/otdfctl/pkg/cli/pipe.go @@ -0,0 +1,45 @@ +package cli + +import ( + "io" + "os" +) + +func ReadFromArgsOrPipe(args []string, pipe *os.File) []byte { + if len(args) > 0 { + return ReadFromFile(args[0]) + } + if pipe == nil { + pipe = os.Stdin + } + return ReadFromPipe(pipe) +} + +func ReadFromPipe(in *os.File) []byte { + stat, err := in.Stat() + if err != nil { + ExitWithError("failed to read stat from stdin", err) + } + if (stat.Mode() & os.ModeCharDevice) == 0 { + buf, err := io.ReadAll(in) + if err != nil { + ExitWithError("failed to scan bytes from stdin", err) + } + return buf + } + return nil +} + +func ReadFromFile(filePath string) []byte { + fileToEncrypt, err := os.Open(filePath) + if err != nil { + ExitWithError("Failed to git open file at path: "+filePath, err) + } + defer fileToEncrypt.Close() + + bytes, err := io.ReadAll(fileToEncrypt) + if err != nil { + ExitWithError("Failed to read bytes from file at path: "+filePath, err) + } + return bytes +} diff --git a/otdfctl/pkg/cli/printer.go b/otdfctl/pkg/cli/printer.go new file mode 100644 index 0000000000..f630bcbf3e --- /dev/null +++ b/otdfctl/pkg/cli/printer.go @@ -0,0 +1,59 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "io" +) + +var ErrPrinterExpectsCommand = errors.New("printer expects a command") + +type Printer struct { + enabled bool + json bool + debug bool +} + +func newPrinter(cli *Cli) *Printer { + p := &Printer{ + enabled: true, + json: false, + debug: false, + } + + // if json output is enabled, disable the printer + printJSON := cli.Flags.GetOptionalBool("json") + p.setJSON(printJSON) + + return p +} + +func (p *Printer) setJSON(json bool) { + p.json = json + p.enabled = !json +} + +// PrintJSON prints the given value as json +// ignores the printer enabled flag +func (c *Cli) printJSON(v interface{}, w io.Writer) { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + encoder.SetEscapeHTML(false) + if err := encoder.Encode(v); err != nil { + ExitWithError("failed to encode json", err) + } +} + +func (c *Cli) println(w io.Writer, args ...interface{}) { + if c.printer.enabled { + fmt.Fprintln(w, args...) + } +} + +func (c *Cli) SetJSONOutput(enabled bool) { + if c.printer == nil { + return + } + c.printer.setJSON(enabled) +} diff --git a/otdfctl/pkg/cli/sdkHelpers.go b/otdfctl/pkg/cli/sdkHelpers.go new file mode 100644 index 0000000000..4b1fb9a5c3 --- /dev/null +++ b/otdfctl/pkg/cli/sdkHelpers.go @@ -0,0 +1,175 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/opentdf/platform/otdfctl/pkg/handlers" + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" +) + +type SimpleAttribute struct { + ID string + Name string + Rule string + Values []string + Namespace string + Active string + AllowTraversal string + Metadata map[string]string +} + +type SimpleAttributeValue struct { + ID string + FQN string + Active string + Metadata map[string]string +} + +func ConstructMetadata(m *common.Metadata) map[string]string { + var metadata map[string]string + if m == nil { + return metadata + } + metadata = map[string]string{ + "Created At": m.GetCreatedAt().AsTime().Format(time.UnixDate), + "Updated At": m.GetUpdatedAt().AsTime().Format(time.UnixDate), + } + + labels := []string{} + if m.Labels != nil { + for k, v := range m.GetLabels() { + labels = append(labels, k+": "+v) + } + } + metadata["Labels"] = CommaSeparated(labels) + return metadata +} + +func GetSimpleAttribute(a *policy.Attribute) SimpleAttribute { + values := []string{} + for _, v := range a.GetValues() { + values = append(values, v.GetValue()) + } + + return SimpleAttribute{ + ID: a.GetId(), + Name: a.GetName(), + Rule: handlers.GetAttributeRuleFromAttributeType(a.GetRule()), + Values: values, + Namespace: a.GetNamespace().GetName(), + Active: strconv.FormatBool(a.GetActive().GetValue()), + AllowTraversal: strconv.FormatBool(a.GetAllowTraversal().GetValue()), + Metadata: ConstructMetadata(a.GetMetadata()), + } +} + +func GetSimpleAttributeValue(v *policy.Value) SimpleAttributeValue { + return SimpleAttributeValue{ + ID: v.GetId(), + FQN: v.GetFqn(), + Active: strconv.FormatBool(v.GetActive().GetValue()), + Metadata: ConstructMetadata(v.GetMetadata()), + } +} + +func GetSimpleObligationValues(v []*policy.ObligationValue) []string { + values := make([]string, len(v)) + for i, val := range v { + values[i] = val.GetValue() + } + return values +} + +func GetSimpleRegisteredResourceValues(v []*policy.RegisteredResourceValue) []string { + values := make([]string, len(v)) + for i, val := range v { + values[i] = val.GetValue() + } + return values +} + +func GetSimpleRegisteredResourceActionAttributeValues(v []*policy.RegisteredResourceValue_ActionAttributeValue) []string { + values := make([]string, len(v)) + sb := new(strings.Builder) + + for i, val := range v { + action := val.GetAction() + attrVal := val.GetAttributeValue() + + sb.WriteString(action.GetName()) + sb.WriteString(" -> ") + sb.WriteString(attrVal.GetFqn()) + + values[i] = sb.String() + sb.Reset() + } + + return values +} + +func KeyAlgToEnum(alg string) (policy.Algorithm, error) { + switch strings.ToLower(alg) { + case "rsa:2048": + return policy.Algorithm_ALGORITHM_RSA_2048, nil + case "rsa:4096": + return policy.Algorithm_ALGORITHM_RSA_4096, nil + case "ec:secp256r1": + return policy.Algorithm_ALGORITHM_EC_P256, nil + case "ec:secp384r1": + return policy.Algorithm_ALGORITHM_EC_P384, nil + case "ec:secp521r1": + return policy.Algorithm_ALGORITHM_EC_P521, nil + default: + return policy.Algorithm_ALGORITHM_UNSPECIFIED, errors.New("invalid algorithm") + } +} + +func KeyEnumToAlg(enum policy.Algorithm) (string, error) { + switch enum { //nolint:exhaustive // UNSPECIFIED is not needed here + case policy.Algorithm_ALGORITHM_RSA_2048: + return "rsa:2048", nil + case policy.Algorithm_ALGORITHM_RSA_4096: + return "rsa:4096", nil + case policy.Algorithm_ALGORITHM_EC_P256: + return "ec:secp256r1", nil + case policy.Algorithm_ALGORITHM_EC_P384: + return "ec:secp384r1", nil + case policy.Algorithm_ALGORITHM_EC_P521: + return "ec:secp521r1", nil + default: + return "", errors.New("invalid enum algorithm") + } +} + +func AggregateClientIDs(reqCtx []*policy.RequestContext) []string { + ids := []string{} + seen := map[string]bool{} + for _, r := range reqCtx { + id := r.GetPep().GetClientId() + if id != "" && !seen[id] { + ids = append(ids, id) + seen[id] = true + } + } + return ids +} + +// Gets JSON from either a file path or a JSON string +func GetJSONInput(data string) (string, error) { + if _, err := os.Stat(data); err == nil { + // It's a file path, read the content + fileContent, err := os.ReadFile(data) + if err != nil { + return "", fmt.Errorf("failed to read file %s: %w", data, err) + } + return string(fileContent), nil + } + + return data, nil +} diff --git a/otdfctl/pkg/cli/style.go b/otdfctl/pkg/cli/style.go new file mode 100644 index 0000000000..07ca3aa7dc --- /dev/null +++ b/otdfctl/pkg/cli/style.go @@ -0,0 +1,372 @@ +//nolint:mnd // styling is magic +package cli + +import "github.com/charmbracelet/lipgloss" + +type Color struct { + Foreground lipgloss.CompleteAdaptiveColor + Background lipgloss.CompleteAdaptiveColor +} + +var colorRed = Color{ + Foreground: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#FF0000", + ANSI256: "9", + ANSI: "1", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#FF0000", + ANSI256: "9", + ANSI: "1", + }, + }, + Background: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#FFD2D2", + ANSI256: "224", + ANSI: "7", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#da6b81", + ANSI256: "52", + ANSI: "4", + }, + }, +} + +var colorOrange = Color{ + Foreground: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#FFA500", + ANSI256: "214", + ANSI: "3", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#FFA500", + ANSI256: "214", + ANSI: "3", + }, + }, + Background: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#FFEBCC", + ANSI256: "230", + ANSI: "7", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#663300", + ANSI256: "94", + ANSI: "4", + }, + }, +} + +//lint:ignore U1000 // not used yet +var colorYellow = Color{ + Foreground: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#FFFF00", + ANSI256: "11", + ANSI: "3", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#FFFF00", + ANSI256: "11", + ANSI: "3", + }, + }, + Background: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#FFFFE0", + ANSI256: "229", + ANSI: "7", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#666600", + ANSI256: "100", + ANSI: "4", + }, + }, +} + +func ColorYellow() Color { + return colorYellow +} + +var colorGreen = Color{ + Foreground: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#008000", + ANSI256: "28", + ANSI: "2", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#008000", + ANSI256: "28", + ANSI: "2", + }, + }, + Background: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#D2FFD2", + ANSI256: "157", + ANSI: "7", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#29cf68", + ANSI256: "22", + ANSI: "4", + }, + }, +} + +var colorBlue = Color{ + Foreground: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#0000FF", + ANSI256: "21", + ANSI: "4", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#3355d3", + ANSI256: "21", + ANSI: "4", + }, + }, + Background: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#7d8ad1", + ANSI256: "189", + ANSI: "7", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#85a2d0", + ANSI256: "17", + ANSI: "4", + }, + }, +} + +var colorIndigo = Color{ + Foreground: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#4B0082", + ANSI256: "57", + ANSI: "5", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#4B0082", + ANSI256: "57", + ANSI: "5", + }, + }, + Background: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#E6E6FA", + ANSI256: "225", + ANSI: "7", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#2A0033", + ANSI256: "55", + ANSI: "4", + }, + }, +} + +//lint:ignore U1000 // not used yet +var colorViolet = Color{ + Foreground: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#EE82EE", + ANSI256: "13", + ANSI: "5", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#EE82EE", + ANSI256: "13", + ANSI: "5", + }, + }, + Background: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#F5E6FF", + ANSI256: "189", + ANSI: "7", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#550055", + ANSI256: "90", + ANSI: "4", + }, + }, +} + +var colorGray = Color{ + Foreground: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#808080", + ANSI256: "244", + ANSI: "7", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#808080", + ANSI256: "244", + ANSI: "7", + }, + }, + Background: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#F2F2F2", + ANSI256: "231", + ANSI: "7", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#333333", + ANSI256: "235", + ANSI: "0", + }, + }, +} + +var colorWhite = Color{ + Foreground: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#FFFFFF", + ANSI256: "15", + ANSI: "7", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#FFFFFF", + ANSI256: "15", + ANSI: "7", + }, + }, + Background: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#F5F5F5", + ANSI256: "231", + ANSI: "7", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#333333", + ANSI256: "235", + ANSI: "0", + }, + }, +} + +var colorBlack = Color{ + Foreground: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#000000", + ANSI256: "0", + ANSI: "0", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#000000", + ANSI256: "0", + ANSI: "0", + }, + }, + Background: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{ + TrueColor: "#E0E0E0", + ANSI256: "248", + ANSI: "7", + }, + Dark: lipgloss.CompleteColor{ + TrueColor: "#121212", + ANSI256: "235", + ANSI: "0", + }, + }, +} + +//////// + +var statusBarStyle = lipgloss.NewStyle(). + Padding(1). + MarginRight(1) + +var styleSuccessStatusBar = lipgloss.NewStyle(). + Inherit(statusBarStyle). + Foreground(colorBlack.Foreground). + Background(colorGreen.Background). + Padding(0, 2). + MarginRight(1). + MarginBottom(1) + +var styleErrorStatusBar = lipgloss.NewStyle(). + Inherit(statusBarStyle). + Foreground(colorBlack.Foreground). + Background(colorRed.Background). + Padding(0, 3). + PaddingRight(3). + MarginRight(1) + +//lint:ignore U1000 // not used yet +var styleNoteStatusBar = lipgloss.NewStyle(). + Inherit(statusBarStyle). + Foreground(colorYellow.Foreground). + Background(colorYellow.Background) + +var styleDebugStatusBar = lipgloss.NewStyle(). + Inherit(statusBarStyle). + Foreground(colorBlack.Foreground). + Background(colorIndigo.Background). + PaddingRight(3) + +var styleWarningStatusBar = lipgloss.NewStyle(). + Inherit(statusBarStyle). + Foreground(colorOrange.Foreground). + Background(colorOrange.Background). + Padding(0, 2). + MarginRight(1) + +var footerLabelStyle = lipgloss.NewStyle(). + Inherit(statusBarStyle). + Foreground(colorWhite.Foreground). + Background(colorBlue.Background). + Padding(0, 2) + +var footerTextStyle = lipgloss. + NewStyle(). + Background(colorGray.Background). + PaddingLeft(1). + Inherit(statusBarStyle) + +// Table + +var styleTableBorder = lipgloss.CompleteAdaptiveColor{ + Light: colorIndigo.Background.Dark, + Dark: colorIndigo.Background.Light, +} + +var styleTable = lipgloss. + NewStyle(). + Foreground(lipgloss.CompleteAdaptiveColor{ + Light: colorBlack.Foreground.Light, + Dark: colorWhite.Foreground.Dark, + }). + BorderForeground(styleTableBorder) + +// Text + +//lint:ignore U1000 // not used yet +var styleText = lipgloss. + NewStyle(). + Foreground(lipgloss.CompleteAdaptiveColor{ + Light: colorBlack.Foreground.Light, + Dark: colorWhite.Foreground.Dark, + }) + +//lint:ignore U1000 // not used yet +var styleTextBold = lipgloss. + NewStyle(). + Foreground(lipgloss.CompleteAdaptiveColor{ + Light: colorBlack.Foreground.Light, + Dark: colorWhite.Foreground.Dark, + }). + Bold(true) diff --git a/otdfctl/pkg/cli/table.go b/otdfctl/pkg/cli/table.go new file mode 100644 index 0000000000..7564bf9ac0 --- /dev/null +++ b/otdfctl/pkg/cli/table.go @@ -0,0 +1,46 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/evertras/bubble-table/table" + "github.com/opentdf/platform/protocol/go/policy" +) + +const ( + FlexColumnWidthOne = 1 + FlexColumnWidthTwo = 2 + FlexColumnWidthThree = 3 + FlexColumnWidthFour = 4 + FlexColumnWidthFive = 5 +) + +func NewTable(cols ...table.Column) table.Model { + return table.New(cols). + BorderRounded(). + WithBaseStyle(styleTable). + WithNoPagination(). + WithTargetWidth(TermWidth()) +} + +func NewUUIDColumn() table.Column { + return table.NewFlexColumn("id", "ID", FlexColumnWidthFive) +} + +// Adds the page information to the table footer +func WithListPaginationFooter(t table.Model, p *policy.PageResponse) table.Model { + info := []string{ + fmt.Sprintf("Total: %d", p.GetTotal()), + fmt.Sprintf("Current Offset: %d", p.GetCurrentOffset()), + } + if p.GetNextOffset() > 0 { + info = append(info, fmt.Sprintf("Next Offset: %d", p.GetNextOffset())) + } + + content := strings.Join(info, " | ") + + leftAligned := lipgloss.NewStyle().Align(lipgloss.Left) + return t.WithStaticFooter(content).WithBaseStyle(leftAligned) +} diff --git a/otdfctl/pkg/cli/tabular.go b/otdfctl/pkg/cli/tabular.go new file mode 100644 index 0000000000..9a9343c64d --- /dev/null +++ b/otdfctl/pkg/cli/tabular.go @@ -0,0 +1,98 @@ +//nolint:forbidigo // should be able to print tables as needed +package cli + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/evertras/bubble-table/table" + "github.com/spf13/cobra" +) + +func NewTabular(rows ...[]string) table.Model { + columnKeyProperty := "Property" + columnKeyValue := "Value" + t := NewTable( + table.NewFlexColumn(columnKeyProperty, columnKeyProperty, FlexColumnWidthOne), + table.NewFlexColumn(columnKeyValue, columnKeyValue, FlexColumnWidthTwo), + ) + + tr := []table.Row{} + if len(rows) == 0 { + tr = append(tr, table.NewRow(table.RowData{ + columnKeyProperty: "No properties found", + columnKeyValue: "", + })) + } + for _, r := range rows { + p := r[0] + v := "" + if len(r) > 1 { + v = r[1] + } + tr = append(tr, table.NewRow(table.RowData{ + columnKeyProperty: p, + columnKeyValue: v, + })) + } + + t = t.WithTargetWidth(TermWidth()) + + t = t.WithRows(tr) + return t +} + +func getJSONHelper(command string) string { + return fmt.Sprintf("Use '%s --json' to see all properties", command) +} + +func PrintSuccessTable(cmd *cobra.Command, id string, t table.Model) { + parent := cmd.Parent() + resourceShort := parent.Use + resource := parent.Use + for parent.Parent() != nil { + resource = parent.Parent().Use + " " + resource + parent = parent.Parent() + } + + var msg struct { + verb string + helper string + } + switch cmd.Use { + case ActionGet: + msg.verb = fmt.Sprintf("Found %s: %s", resourceShort, id) + msg.helper = getJSONHelper(resource + " get --id=" + id) + case ActionCreate: + msg.verb = fmt.Sprintf("Created %s: %s", resourceShort, id) + msg.helper = getJSONHelper(resource + " get --id=" + id) + case ActionUpdate: + msg.verb = fmt.Sprintf("Updated %s: %s", resourceShort, id) + msg.helper = getJSONHelper(resource + " get --id=" + id) + case ActionDelete: + msg.verb = fmt.Sprintf("Deleted %s: %s", resourceShort, id) + // strip off unsafe subcommand if found to get proper path to the list command + msg.helper = getJSONHelper(strings.ReplaceAll(resource, " unsafe", "") + " list") + case ActionDeactivate: + msg.verb = fmt.Sprintf("Deactivated %s: %s", resourceShort, id) + msg.helper = getJSONHelper(resource + " list") // TODO: make sure the filters are provided here to get ACTIVE/INACTIVE/ANY + case ActionList: + msg.verb = fmt.Sprintf("Found %s list", resourceShort) + msg.helper = getJSONHelper(resource + " get --id=") + default: + msg.verb = "" + msg.helper = "" + } + + successMessage := SuccessMessage(msg.verb) + jsonDirections := FooterMessage(msg.helper) + + ts := t.View() + if ts == "" { + fmt.Println(lipgloss.JoinVertical(lipgloss.Top, successMessage, jsonDirections)) + return + } + + fmt.Println(lipgloss.JoinVertical(lipgloss.Top, successMessage, ts, jsonDirections)) +} diff --git a/otdfctl/pkg/cli/utils.go b/otdfctl/pkg/cli/utils.go new file mode 100644 index 0000000000..ff7317e2a9 --- /dev/null +++ b/otdfctl/pkg/cli/utils.go @@ -0,0 +1,50 @@ +package cli + +import ( + "os" + "strconv" + "strings" + + "github.com/opentdf/platform/otdfctl/pkg/config" + "golang.org/x/term" +) + +func CommaSeparated(values []string) string { + return "[" + strings.Join(values, ", ") + "]" +} + +var defaultWidth = 80 + +// Returns the terminal width (overridden by env var for testing) +func TermWidth() int { + var ( + w int + err error + ) + testSize := os.Getenv(config.TestTerminalWidth) + if testSize == "" { + w, _, err = term.GetSize(0) + if err != nil { + return defaultWidth + } + return w + } + if w, err = strconv.Atoi(testSize); err != nil { + return defaultWidth + } + return w +} + +func PrettyList(values []string) string { + var b strings.Builder + for i, v := range values { + if i == len(values)-1 { + b.WriteString("or ") + b.WriteString(v) + } else { + b.WriteString(v) + b.WriteString(", ") + } + } + return b.String() +} diff --git a/otdfctl/pkg/config/config.go b/otdfctl/pkg/config/config.go new file mode 100644 index 0000000000..550a855570 --- /dev/null +++ b/otdfctl/pkg/config/config.go @@ -0,0 +1,21 @@ +package config + +var ( + // Name of the publisher (PascalCase for when it is used in the UI and file system directory names) + ServicePublisher = "VirtruCorporation" + // AppName is the name of the application + // Note: use caution when renaming as it is used in various places within the CLI including for + // config file naming and in the profile store + AppName = "otdfctl" + + Version = "0.30.0" // x-release-please-version + BuildTime = "1970-01-01T00:00:00Z" + CommitSha = "0000000" + + // Test mode is used to determine if the application is running in test mode + // "true" = running in test mode + TestMode = "" + + // Test terminal size is a runtime env var to allow for testing of terminal output + TestTerminalWidth = "TEST_TERMINAL_WIDTH" +) diff --git a/otdfctl/pkg/handlers/actions.go b/otdfctl/pkg/handlers/actions.go new file mode 100644 index 0000000000..7f8db58dee --- /dev/null +++ b/otdfctl/pkg/handlers/actions.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "context" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/actions" +) + +func (h Handler) GetAction(ctx context.Context, id string, name string, namespace string) (*policy.Action, error) { + req := &actions.GetActionRequest{} + if id != "" { + req.Identifier = &actions.GetActionRequest_Id{ + Id: id, + } + } else { + req.Identifier = &actions.GetActionRequest_Name{ + Name: name, + } + } + + req.NamespaceId, req.NamespaceFqn = getNamespaceIDAndFQN(namespace) + + resp, err := h.sdk.Actions.GetAction(ctx, req) + if err != nil { + return nil, err + } + + return resp.GetAction(), nil +} + +func (h Handler) ListActions(ctx context.Context, limit, offset int32, namespace string) (*actions.ListActionsResponse, error) { + req := &actions.ListActionsRequest{ + Pagination: &policy.PageRequest{ + Limit: limit, + Offset: offset, + }, + } + req.NamespaceId, req.NamespaceFqn = getNamespaceIDAndFQN(namespace) + + return h.sdk.Actions.ListActions(ctx, req) +} + +func (h Handler) CreateAction(ctx context.Context, name string, namespace string, metadata *common.MetadataMutable) (*policy.Action, error) { + req := &actions.CreateActionRequest{ + Name: name, + Metadata: metadata, + } + req.NamespaceId, req.NamespaceFqn = getNamespaceIDAndFQN(namespace) + + resp, err := h.sdk.Actions.CreateAction(ctx, req) + if err != nil { + return nil, err + } + + return resp.GetAction(), nil +} + +func (h Handler) UpdateAction(ctx context.Context, id, name string, metadata *common.MetadataMutable, behavior common.MetadataUpdateEnum) (*policy.Action, error) { + _, err := h.sdk.Actions.UpdateAction(ctx, &actions.UpdateActionRequest{ + Id: id, + Metadata: metadata, + Name: name, + MetadataUpdateBehavior: behavior, + }) + if err != nil { + return nil, err + } + return h.GetAction(ctx, id, "", "") +} + +func (h Handler) DeleteAction(ctx context.Context, id string) error { + _, err := h.sdk.Actions.DeleteAction(ctx, &actions.DeleteActionRequest{ + Id: id, + }) + return err +} diff --git a/otdfctl/pkg/handlers/attribute.go b/otdfctl/pkg/handlers/attribute.go new file mode 100644 index 0000000000..d420cdffe8 --- /dev/null +++ b/otdfctl/pkg/handlers/attribute.go @@ -0,0 +1,252 @@ +package handlers + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/protocol/go/policy/unsafe" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// TODO: Might be useful to map out the attribute rule definitions for help text in the CLI and TUI + +const ( + AttributeRuleAllOf = "ALL_OF" + AttributeRuleAnyOf = "ANY_OF" + AttributeRuleHierarchy = "HIERARCHY" +) + +type CreateAttributeError struct { + ValueErrors map[string]error + + Err error +} + +func (e *CreateAttributeError) Error() string { + if e.ValueErrors != nil { + return "Error creating attribute with values" + fmt.Sprintf("%v", e.ValueErrors) + } + + return "Error creating attribute" +} + +func (h Handler) GetAttribute(ctx context.Context, identifier string) (*policy.Attribute, error) { + req := &attributes.GetAttributeRequest{ + Identifier: &attributes.GetAttributeRequest_AttributeId{ + AttributeId: identifier, + }, + } + if _, err := uuid.Parse(identifier); err != nil { + req.Identifier = &attributes.GetAttributeRequest_Fqn{ + Fqn: identifier, + } + } + + resp, err := h.sdk.Attributes.GetAttribute(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get attribute [%s]: %w", identifier, err) + } + + return resp.GetAttribute(), nil +} + +func (h Handler) ListAttributes(ctx context.Context, state common.ActiveStateEnum, limit, offset int32) (*attributes.ListAttributesResponse, error) { + return h.sdk.Attributes.ListAttributes(ctx, &attributes.ListAttributesRequest{ + State: state, + Pagination: &policy.PageRequest{ + Limit: limit, + Offset: offset, + }, + }) +} + +// Creates and returns the created attribute +func (h Handler) CreateAttribute(ctx context.Context, name string, rule string, namespace string, values []string, metadata *common.MetadataMutable, allowTraversal *wrapperspb.BoolValue) (*policy.Attribute, error) { + r, err := GetAttributeRuleFromReadableString(rule) + if err != nil { + return nil, err + } + + attrReq := &attributes.CreateAttributeRequest{ + NamespaceId: namespace, + Name: name, + Rule: r, + Metadata: metadata, + Values: values, + } + if allowTraversal != nil { + attrReq.AllowTraversal = allowTraversal + } + + resp, err := h.sdk.Attributes.CreateAttribute(ctx, attrReq) + if err != nil { + return nil, err + } + + return h.GetAttribute(ctx, resp.GetAttribute().GetId()) +} + +// Updates and returns updated attribute +func (h *Handler) UpdateAttribute( + ctx context.Context, + id string, + metadata *common.MetadataMutable, + behavior common.MetadataUpdateEnum, +) (*policy.Attribute, error) { + _, err := h.sdk.Attributes.UpdateAttribute(ctx, &attributes.UpdateAttributeRequest{ + Id: id, + Metadata: metadata, + MetadataUpdateBehavior: behavior, + }) + if err != nil { + return nil, err + } + return h.GetAttribute(ctx, id) +} + +// Deactivates and returns deactivated attribute +func (h Handler) DeactivateAttribute(ctx context.Context, id string) (*policy.Attribute, error) { + _, err := h.sdk.Attributes.DeactivateAttribute(ctx, &attributes.DeactivateAttributeRequest{ + Id: id, + }) + if err != nil { + return nil, err + } + return h.GetAttribute(ctx, id) +} + +// Reactivates and returns reactivated attribute +func (h Handler) UnsafeReactivateAttribute(ctx context.Context, id string) (*policy.Attribute, error) { + _, err := h.sdk.Unsafe.UnsafeReactivateAttribute(ctx, &unsafe.UnsafeReactivateAttributeRequest{ + Id: id, + }) + if err != nil { + return nil, err + } + return h.GetAttribute(ctx, id) +} + +// Deletes and returns error if deletion failed +func (h Handler) UnsafeDeleteAttribute(ctx context.Context, id, fqn string) error { + _, err := h.sdk.Unsafe.UnsafeDeleteAttribute(ctx, &unsafe.UnsafeDeleteAttributeRequest{ + Id: id, + Fqn: fqn, + }) + return err +} + +// Deletes and returns error if deletion failed +func (h Handler) UnsafeUpdateAttribute(ctx context.Context, id, name, rule string, valuesOrder []string, allowTraversal *wrapperspb.BoolValue) (*policy.Attribute, error) { + req := &unsafe.UnsafeUpdateAttributeRequest{ + Id: id, + Name: name, + } + + if rule != "" { + r, err := GetAttributeRuleFromReadableString(rule) + if err != nil { + return nil, fmt.Errorf("invalid attribute rule: %s", rule) + } + req.Rule = r + } + if len(valuesOrder) > 0 { + req.ValuesOrder = valuesOrder + } + if allowTraversal != nil { + req.AllowTraversal = allowTraversal + } + + _, err := h.sdk.Unsafe.UnsafeUpdateAttribute(ctx, req) + if err != nil { + return nil, err + } + return h.GetAttribute(ctx, id) +} + +func (h Handler) AssignKeyToAttribute(ctx context.Context, attr, keyID string) (*attributes.AttributeKey, error) { + attrKey := &attributes.AttributeKey{ + KeyId: keyID, + AttributeId: attr, + } + if _, err := uuid.Parse(attr); err != nil { + attr, err := h.GetAttribute(ctx, attr) + if err != nil { + return nil, err + } + attrKey.AttributeId = attr.GetId() + } + resp, err := h.sdk.Attributes.AssignPublicKeyToAttribute(ctx, &attributes.AssignPublicKeyToAttributeRequest{ + AttributeKey: attrKey, + }) + if err != nil { + return nil, err + } + + return resp.GetAttributeKey(), nil +} + +func (h Handler) RemoveKeyFromAttribute(ctx context.Context, attr, keyID string) error { + attrKey := &attributes.AttributeKey{ + KeyId: keyID, + AttributeId: attr, + } + if _, err := uuid.Parse(attr); err != nil { + attr, err := h.GetAttribute(ctx, attr) + if err != nil { + return err + } + attrKey.AttributeId = attr.GetId() + } + _, err := h.sdk.Attributes.RemovePublicKeyFromAttribute(ctx, &attributes.RemovePublicKeyFromAttributeRequest{ + AttributeKey: attrKey, + }) + if err != nil { + return err + } + + return nil +} + +func GetAttributeFqn(namespace string, name string) string { + return fmt.Sprintf("https://%s/attr/%s", namespace, name) +} + +func GetAttributeRuleOptions() []string { + return []string{ + AttributeRuleAllOf, + AttributeRuleAnyOf, + AttributeRuleHierarchy, + } +} + +// Provides the un-prefixed human-readable attribute rule +func GetAttributeRuleFromAttributeType(rule policy.AttributeRuleTypeEnum) string { + //nolint:exhaustive // should not consider UNSPECIFIED + switch rule { + case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF: + return AttributeRuleAllOf + case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF: + return AttributeRuleAnyOf + case policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY: + return AttributeRuleHierarchy + default: + return "" + } +} + +func GetAttributeRuleFromReadableString(rule string) (policy.AttributeRuleTypeEnum, error) { + // should not consider UNSPECIFIED + switch rule { + case AttributeRuleAllOf: + return policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF, nil + case AttributeRuleAnyOf: + return policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF, nil + case AttributeRuleHierarchy: + return policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY, nil + } + return 0, fmt.Errorf("invalid attribute rule: %s, must be one of [%s, %s, %s]", rule, AttributeRuleAllOf, AttributeRuleAnyOf, AttributeRuleHierarchy) +} diff --git a/otdfctl/pkg/handlers/attributeValues.go b/otdfctl/pkg/handlers/attributeValues.go new file mode 100644 index 0000000000..836aa243f0 --- /dev/null +++ b/otdfctl/pkg/handlers/attributeValues.go @@ -0,0 +1,156 @@ +package handlers + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/protocol/go/policy/unsafe" +) + +// ListAttributeValues fetches all values via GetAttribute; client-side filtering replaces the deprecated ListAttributeValues RPC. +func (h *Handler) ListAttributeValues(ctx context.Context, attributeID string) ([]*policy.Value, error) { + attr, err := h.GetAttribute(ctx, attributeID) + if err != nil { + return nil, err + } + return attr.GetValues(), nil +} + +// Creates and returns the created value +func (h *Handler) CreateAttributeValue(ctx context.Context, attributeID string, value string, metadata *common.MetadataMutable) (*policy.Value, error) { + resp, err := h.sdk.Attributes.CreateAttributeValue(ctx, &attributes.CreateAttributeValueRequest{ + AttributeId: attributeID, + Value: value, + Metadata: metadata, + }) + if err != nil { + return nil, err + } + + return h.GetAttributeValue(ctx, resp.GetValue().GetId()) +} + +func (h *Handler) GetAttributeValue(ctx context.Context, identifier string) (*policy.Value, error) { + req := &attributes.GetAttributeValueRequest{ + Identifier: &attributes.GetAttributeValueRequest_ValueId{ + ValueId: identifier, + }, + } + if _, err := uuid.Parse(identifier); err != nil { + req.Identifier = &attributes.GetAttributeValueRequest_Fqn{ + Fqn: identifier, + } + } + resp, err := h.sdk.Attributes.GetAttributeValue(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get attribute value [%s]: %w", identifier, err) + } + + return resp.GetValue(), nil +} + +// Updates and returns updated value +func (h *Handler) UpdateAttributeValue(ctx context.Context, id string, metadata *common.MetadataMutable, behavior common.MetadataUpdateEnum) (*policy.Value, error) { + resp, err := h.sdk.Attributes.UpdateAttributeValue(ctx, &attributes.UpdateAttributeValueRequest{ + Id: id, + Metadata: metadata, + MetadataUpdateBehavior: behavior, + }) + if err != nil { + return nil, err + } + + return h.GetAttributeValue(ctx, resp.GetValue().GetId()) +} + +// Deactivates and returns deactivated value +func (h *Handler) DeactivateAttributeValue(ctx context.Context, id string) (*policy.Value, error) { + _, err := h.sdk.Attributes.DeactivateAttributeValue(ctx, &attributes.DeactivateAttributeValueRequest{ + Id: id, + }) + if err != nil { + return nil, err + } + return h.GetAttributeValue(ctx, id) +} + +// Reactivates and returns reactivated attribute +func (h Handler) UnsafeReactivateAttributeValue(ctx context.Context, id string) (*policy.Value, error) { + _, err := h.sdk.Unsafe.UnsafeReactivateAttributeValue(ctx, &unsafe.UnsafeReactivateAttributeValueRequest{ + Id: id, + }) + if err != nil { + return nil, err + } + return h.GetAttributeValue(ctx, id) +} + +// Deletes and returns error if deletion failed +func (h Handler) UnsafeDeleteAttributeValue(ctx context.Context, id, fqn string) error { + _, err := h.sdk.Unsafe.UnsafeDeleteAttributeValue(ctx, &unsafe.UnsafeDeleteAttributeValueRequest{ + Id: id, + Fqn: fqn, + }) + return err +} + +// Deletes and returns error if deletion failed +func (h Handler) UnsafeUpdateAttributeValue(ctx context.Context, id, value string) error { + req := &unsafe.UnsafeUpdateAttributeValueRequest{ + Id: id, + Value: value, + } + + _, err := h.sdk.Unsafe.UnsafeUpdateAttributeValue(ctx, req) + return err +} + +// AssignKeyToAttributeValue assigns a KAS key to an attribute value +func (h *Handler) AssignKeyToAttributeValue(ctx context.Context, value, keyID string) (*attributes.ValueKey, error) { + valueKey := &attributes.ValueKey{ + KeyId: keyID, + ValueId: value, + } + + if _, err := uuid.Parse(value); err != nil { + attrValue, err := h.GetAttributeValue(ctx, value) + if err != nil { + return nil, err + } + valueKey.ValueId = attrValue.GetId() + } + + resp, err := h.sdk.Attributes.AssignPublicKeyToValue(ctx, &attributes.AssignPublicKeyToValueRequest{ + ValueKey: valueKey, + }) + if err != nil { + return nil, err + } + + return resp.GetValueKey(), nil +} + +// RemoveKeyFromAttributeValue removes a KAS key from an attribute value +func (h *Handler) RemoveKeyFromAttributeValue(ctx context.Context, value, keyID string) error { + valueKey := &attributes.ValueKey{ + KeyId: keyID, + ValueId: value, + } + + if _, err := uuid.Parse(value); err != nil { + attrValue, err := h.GetAttributeValue(ctx, value) + if err != nil { + return err + } + valueKey.ValueId = attrValue.GetId() + } + + _, err := h.sdk.Attributes.RemovePublicKeyFromValue(ctx, &attributes.RemovePublicKeyFromValueRequest{ + ValueKey: valueKey, + }) + return err +} diff --git a/otdfctl/pkg/handlers/base-keys.go b/otdfctl/pkg/handlers/base-keys.go new file mode 100644 index 0000000000..dd8e8d0976 --- /dev/null +++ b/otdfctl/pkg/handlers/base-keys.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "context" + + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/kasregistry" +) + +// GetBaseKey retrieves a base key from the KAS registry. +// This is a stub function and needs to be implemented. +func (h Handler) GetBaseKey(ctx context.Context) (*policy.SimpleKasKey, error) { + resp, err := h.sdk.KeyAccessServerRegistry.GetBaseKey(ctx, &kasregistry.GetBaseKeyRequest{}) + if err != nil { + return nil, err + } + + return resp.GetBaseKey(), nil +} + +func (h Handler) SetBaseKey(ctx context.Context, id string, key *kasregistry.KasKeyIdentifier) (*kasregistry.SetBaseKeyResponse, error) { + req := kasregistry.SetBaseKeyRequest{} + + if id != "" && key == nil { + req.ActiveKey = &kasregistry.SetBaseKeyRequest_Id{ + Id: id, + } + } else if key != nil { + req.ActiveKey = &kasregistry.SetBaseKeyRequest_Key{ + Key: key, + } + } + + return h.sdk.KeyAccessServerRegistry.SetBaseKey(ctx, &req) +} diff --git a/otdfctl/pkg/handlers/kas-grants.go b/otdfctl/pkg/handlers/kas-grants.go new file mode 100644 index 0000000000..0b09bb994a --- /dev/null +++ b/otdfctl/pkg/handlers/kas-grants.go @@ -0,0 +1,72 @@ +//nolint:staticcheck // deprecated KAS grant functions are still supported while migrating +package handlers + +import ( + "context" + + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/attributes" + "github.com/opentdf/platform/protocol/go/policy/kasregistry" + "github.com/opentdf/platform/protocol/go/policy/namespaces" +) + +func (h Handler) DeleteKasGrantFromAttribute(ctx context.Context, attrID string, kasID string) (*attributes.AttributeKeyAccessServer, error) { + kas := &attributes.AttributeKeyAccessServer{ + AttributeId: attrID, + KeyAccessServerId: kasID, + } + resp, err := h.sdk.Attributes.RemoveKeyAccessServerFromAttribute(ctx, &attributes.RemoveKeyAccessServerFromAttributeRequest{ + AttributeKeyAccessServer: kas, + }) + if err != nil { + return nil, err + } + + return resp.GetAttributeKeyAccessServer(), nil +} + +func (h Handler) DeleteKasGrantFromValue(ctx context.Context, valID string, kasID string) (*attributes.ValueKeyAccessServer, error) { + kas := &attributes.ValueKeyAccessServer{ + ValueId: valID, + KeyAccessServerId: kasID, + } + resp, err := h.sdk.Attributes.RemoveKeyAccessServerFromValue(ctx, &attributes.RemoveKeyAccessServerFromValueRequest{ + ValueKeyAccessServer: kas, + }) + if err != nil { + return nil, err + } + + return resp.GetValueKeyAccessServer(), nil +} + +func (h Handler) DeleteKasGrantFromNamespace(ctx context.Context, nsID string, kasID string) (*namespaces.NamespaceKeyAccessServer, error) { + kas := &namespaces.NamespaceKeyAccessServer{ + NamespaceId: nsID, + KeyAccessServerId: kasID, + } + resp, err := h.sdk.Namespaces.RemoveKeyAccessServerFromNamespace(ctx, &namespaces.RemoveKeyAccessServerFromNamespaceRequest{ + NamespaceKeyAccessServer: kas, + }) + if err != nil { + return nil, err + } + + return resp.GetNamespaceKeyAccessServer(), nil +} + +func (h Handler) ListKasGrants(ctx context.Context, kasID, kasURI string, limit, offset int32) ([]*kasregistry.KeyAccessServerGrants, *policy.PageResponse, error) { + resp, err := h.sdk.KeyAccessServerRegistry.ListKeyAccessServerGrants(ctx, &kasregistry.ListKeyAccessServerGrantsRequest{ + KasId: kasID, + KasUri: kasURI, + Pagination: &policy.PageRequest{ + Limit: limit, + Offset: offset, + }, + }) + if err != nil { + return nil, nil, err + } + //nolint:staticcheck // deprecated but not removed while public keys work is experimental + return resp.GetGrants(), resp.GetPagination(), nil +} diff --git a/otdfctl/pkg/handlers/kas-keys.go b/otdfctl/pkg/handlers/kas-keys.go new file mode 100644 index 0000000000..d18fb66c99 --- /dev/null +++ b/otdfctl/pkg/handlers/kas-keys.go @@ -0,0 +1,195 @@ +package handlers + +import ( + "context" + "errors" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/kasregistry" + "github.com/opentdf/platform/protocol/go/policy/unsafe" +) + +type RotateKeyResult struct { + KasKey *policy.KasKey `json:"kas_key"` + RotatedResources *kasregistry.RotatedResources `json:"rotated_resources"` +} + +func (h Handler) CreateKasKey( + ctx context.Context, + kasID string, + keyID string, + alg policy.Algorithm, + mode policy.KeyMode, + pubKeyCtx *policy.PublicKeyCtx, + privKeyCtx *policy.PrivateKeyCtx, + providerConfigID string, + metadata *common.MetadataMutable, + legacy bool, +) (*policy.KasKey, error) { + req := kasregistry.CreateKeyRequest{ + KasId: kasID, + KeyId: keyID, + KeyAlgorithm: alg, + KeyMode: mode, + PublicKeyCtx: pubKeyCtx, + PrivateKeyCtx: privKeyCtx, + ProviderConfigId: providerConfigID, + Metadata: metadata, + Legacy: legacy, + } + + resp, err := h.sdk.KeyAccessServerRegistry.CreateKey(ctx, &req) + if err != nil { + return nil, err + } + + return resp.GetKasKey(), nil +} + +func (h Handler) GetKasKey(ctx context.Context, id string, key *kasregistry.KasKeyIdentifier) (*policy.KasKey, error) { + req := kasregistry.GetKeyRequest{} + switch { + case id != "" && key == nil: + req.Identifier = &kasregistry.GetKeyRequest_Id{ + Id: id, + } + case key != nil: + req.Identifier = &kasregistry.GetKeyRequest_Key{ + Key: key, + } + default: + return nil, errors.New("id or key must be provided") + } + + resp, err := h.sdk.KeyAccessServerRegistry.GetKey(ctx, &req) + if err != nil { + return nil, err + } + + return resp.GetKasKey(), nil +} + +func (h Handler) UpdateKasKey(ctx context.Context, id string, metadata *common.MetadataMutable, behavior common.MetadataUpdateEnum) (*policy.KasKey, error) { + req := kasregistry.UpdateKeyRequest{ + Id: id, + Metadata: metadata, + MetadataUpdateBehavior: behavior, + } + + resp, err := h.sdk.KeyAccessServerRegistry.UpdateKey(ctx, &req) + if err != nil { + return nil, err + } + + return resp.GetKasKey(), nil +} + +func (h Handler) ListKasKeys( + ctx context.Context, + limit, offset int32, + algorithm policy.Algorithm, + identifier KasIdentifier, + legacy *bool, +) (*kasregistry.ListKeysResponse, error) { + req := kasregistry.ListKeysRequest{ + Pagination: &policy.PageRequest{ + Limit: limit, + Offset: offset, + }, + KeyAlgorithm: algorithm, + } + + switch { + case identifier.ID != "": + req.KasFilter = &kasregistry.ListKeysRequest_KasId{ + KasId: identifier.ID, + } + case identifier.Name != "": + req.KasFilter = &kasregistry.ListKeysRequest_KasName{ + KasName: identifier.Name, + } + case identifier.URI != "": + req.KasFilter = &kasregistry.ListKeysRequest_KasUri{ + KasUri: identifier.URI, + } + } + req.Legacy = legacy + + return h.sdk.KeyAccessServerRegistry.ListKeys(ctx, &req) +} + +func (h Handler) ListKeyMappings( + ctx context.Context, + limit, offset int32, + keySystemID string, + keyUserIdentifier *kasregistry.KasKeyIdentifier, +) (*kasregistry.ListKeyMappingsResponse, error) { + req := kasregistry.ListKeyMappingsRequest{ + Pagination: &policy.PageRequest{ + Limit: limit, + Offset: offset, + }, + } + + switch { + case keySystemID != "": + req.Identifier = &kasregistry.ListKeyMappingsRequest_Id{ + Id: keySystemID, + } + case keyUserIdentifier != nil: + req.Identifier = &kasregistry.ListKeyMappingsRequest_Key{ + Key: keyUserIdentifier, + } + } + + resp, err := h.sdk.KeyAccessServerRegistry.ListKeyMappings(ctx, &req) + if err != nil { + return nil, err + } + + return resp, nil +} + +func (h Handler) RotateKasKey( + ctx context.Context, + oldKeyID string, + key *kasregistry.KasKeyIdentifier, + newKey *kasregistry.RotateKeyRequest_NewKey, +) (*RotateKeyResult, error) { + req := kasregistry.RotateKeyRequest{ + NewKey: newKey, + } + + switch { + case oldKeyID != "" && key == nil: + req.ActiveKey = &kasregistry.RotateKeyRequest_Id{ + Id: oldKeyID, + } + case key != nil: + req.ActiveKey = &kasregistry.RotateKeyRequest_Key{ + Key: key, + } + default: + return nil, errors.New("old key id or key must be provided") + } + + resp, err := h.sdk.KeyAccessServerRegistry.RotateKey(ctx, &req) + if err != nil { + return nil, err + } + + return &RotateKeyResult{ + KasKey: resp.GetKasKey(), + RotatedResources: resp.GetRotatedResources(), + }, nil +} + +func (h Handler) UnsafeDeleteKasKey(ctx context.Context, id, kid, kasURI string) (*policy.KasKey, error) { + resp, err := h.sdk.Unsafe.UnsafeDeleteKasKey(ctx, &unsafe.UnsafeDeleteKasKeyRequest{ + Id: id, + Kid: kid, + KasUri: kasURI, + }) + return resp.GetKey(), err +} diff --git a/otdfctl/pkg/handlers/kas-registry.go b/otdfctl/pkg/handlers/kas-registry.go new file mode 100644 index 0000000000..eaca184228 --- /dev/null +++ b/otdfctl/pkg/handlers/kas-registry.go @@ -0,0 +1,102 @@ +package handlers + +import ( + "context" + "errors" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/kasregistry" +) + +type KasIdentifier struct { + ID string + Name string + URI string +} + +func (h Handler) GetKasRegistryEntry(ctx context.Context, identifer KasIdentifier) (*policy.KeyAccessServer, error) { + req := &kasregistry.GetKeyAccessServerRequest{} + switch { + case identifer.ID != "": + req.Identifier = &kasregistry.GetKeyAccessServerRequest_KasId{ + KasId: identifer.ID, + } + case identifer.Name != "": + req.Identifier = &kasregistry.GetKeyAccessServerRequest_Name{ + Name: identifer.Name, + } + case identifer.URI != "": + req.Identifier = &kasregistry.GetKeyAccessServerRequest_Uri{ + Uri: identifer.URI, + } + default: + return nil, errors.New("id, name or uri must be provided") + } + + resp, err := h.sdk.KeyAccessServerRegistry.GetKeyAccessServer(ctx, req) + if err != nil { + return nil, err + } + + return resp.GetKeyAccessServer(), nil +} + +func (h Handler) ListKasRegistryEntries(ctx context.Context, limit, offset int32) (*kasregistry.ListKeyAccessServersResponse, error) { + return h.sdk.KeyAccessServerRegistry.ListKeyAccessServers(ctx, &kasregistry.ListKeyAccessServersRequest{ + Pagination: &policy.PageRequest{ + Limit: limit, + Offset: offset, + }, + }) +} + +// Creates the KAS registry and then returns the KAS +func (h Handler) CreateKasRegistryEntry(ctx context.Context, uri string, name string, metadata *common.MetadataMutable) (*policy.KeyAccessServer, error) { + req := &kasregistry.CreateKeyAccessServerRequest{ + Uri: uri, + Name: name, + Metadata: metadata, + } + + resp, err := h.sdk.KeyAccessServerRegistry.CreateKeyAccessServer(ctx, req) + if err != nil { + return nil, err + } + + return h.GetKasRegistryEntry(ctx, KasIdentifier{ + ID: resp.GetKeyAccessServer().GetId(), + }) +} + +// Updates the KAS registry and then returns the KAS +func (h Handler) UpdateKasRegistryEntry(ctx context.Context, id, uri, name string, metadata *common.MetadataMutable, behavior common.MetadataUpdateEnum) (*policy.KeyAccessServer, error) { + _, err := h.sdk.KeyAccessServerRegistry.UpdateKeyAccessServer(ctx, &kasregistry.UpdateKeyAccessServerRequest{ + Id: id, + Uri: uri, + Name: name, + Metadata: metadata, + MetadataUpdateBehavior: behavior, + }) + if err != nil { + return nil, err + } + + return h.GetKasRegistryEntry(ctx, KasIdentifier{ + ID: id, + }) +} + +// Deletes the KAS registry and returns the deleted KAS +func (h Handler) DeleteKasRegistryEntry(ctx context.Context, id string) (*policy.KeyAccessServer, error) { + req := &kasregistry.DeleteKeyAccessServerRequest{ + Id: id, + } + + resp, err := h.sdk.KeyAccessServerRegistry.DeleteKeyAccessServer(ctx, req) + if err != nil { + return nil, err + } + + return resp.GetKeyAccessServer(), nil +} diff --git a/otdfctl/pkg/handlers/namespaces.go b/otdfctl/pkg/handlers/namespaces.go new file mode 100644 index 0000000000..6cc2284cab --- /dev/null +++ b/otdfctl/pkg/handlers/namespaces.go @@ -0,0 +1,167 @@ +package handlers + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/namespaces" + "github.com/opentdf/platform/protocol/go/policy/unsafe" +) + +func getNamespaceIDAndFQN(namespace string) (string, string) { + if _, err := uuid.Parse(namespace); err != nil { + return "", namespace + } + return namespace, "" +} + +func (h Handler) GetNamespace(ctx context.Context, identifier string) (*policy.Namespace, error) { + req := &namespaces.GetNamespaceRequest{ + Identifier: &namespaces.GetNamespaceRequest_NamespaceId{ + NamespaceId: identifier, + }, + } + if _, err := uuid.Parse(identifier); err != nil { + req.Identifier = &namespaces.GetNamespaceRequest_Fqn{ + Fqn: identifier, + } + } + + resp, err := h.sdk.Namespaces.GetNamespace(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get namespace [%s]: %w", identifier, err) + } + + return resp.GetNamespace(), nil +} + +func (h Handler) ListNamespaces(ctx context.Context, state common.ActiveStateEnum, limit, offset int32) (*namespaces.ListNamespacesResponse, error) { + return h.sdk.Namespaces.ListNamespaces(ctx, &namespaces.ListNamespacesRequest{ + State: state, + Pagination: &policy.PageRequest{ + Limit: limit, + Offset: offset, + }, + }) +} + +// Creates and returns the created n +func (h Handler) CreateNamespace(ctx context.Context, name string, metadata *common.MetadataMutable) (*policy.Namespace, error) { + resp, err := h.sdk.Namespaces.CreateNamespace(ctx, &namespaces.CreateNamespaceRequest{ + Name: name, + Metadata: metadata, + }) + if err != nil { + return nil, err + } + + return h.GetNamespace(ctx, resp.GetNamespace().GetId()) +} + +// Updates and returns the updated namespace +func (h Handler) UpdateNamespace(ctx context.Context, id string, metadata *common.MetadataMutable, behavior common.MetadataUpdateEnum) (*policy.Namespace, error) { + _, err := h.sdk.Namespaces.UpdateNamespace(ctx, &namespaces.UpdateNamespaceRequest{ + Id: id, + Metadata: metadata, + MetadataUpdateBehavior: behavior, + }) + if err != nil { + return nil, err + } + return h.GetNamespace(ctx, id) +} + +// Deactivates and returns the deactivated namespace +func (h Handler) DeactivateNamespace(ctx context.Context, id string) (*policy.Namespace, error) { + _, err := h.sdk.Namespaces.DeactivateNamespace(ctx, &namespaces.DeactivateNamespaceRequest{ + Id: id, + }) + if err != nil { + return nil, err + } + + return h.GetNamespace(ctx, id) +} + +// Reactivates and returns the reactivated namespace +func (h Handler) UnsafeReactivateNamespace(ctx context.Context, id string) (*policy.Namespace, error) { + _, err := h.sdk.Unsafe.UnsafeReactivateNamespace(ctx, &unsafe.UnsafeReactivateNamespaceRequest{ + Id: id, + }) + if err != nil { + return nil, err + } + + return h.GetNamespace(ctx, id) +} + +// Deletes and returns the deleted namespace +func (h Handler) UnsafeDeleteNamespace(ctx context.Context, id string, fqn string) error { + _, err := h.sdk.Unsafe.UnsafeDeleteNamespace(ctx, &unsafe.UnsafeDeleteNamespaceRequest{ + Id: id, + Fqn: fqn, + }) + return err +} + +// Unsafely updates the namespace and returns the renamed namespace +func (h Handler) UnsafeUpdateNamespace(ctx context.Context, id, name string) (*policy.Namespace, error) { + _, err := h.sdk.Unsafe.UnsafeUpdateNamespace(ctx, &unsafe.UnsafeUpdateNamespaceRequest{ + Id: id, + Name: name, + }) + if err != nil { + return nil, err + } + + return h.GetNamespace(ctx, id) +} + +// AssignKeyToAttributeNamespace assigns a KAS key to an attribute namespace +func (h *Handler) AssignKeyToAttributeNamespace(ctx context.Context, namespace, keyID string) (*namespaces.NamespaceKey, error) { + namespaceKey := &namespaces.NamespaceKey{ + KeyId: keyID, + NamespaceId: namespace, + } + + if _, err := uuid.Parse(namespace); err != nil { + ns, err := h.GetNamespace(ctx, namespace) + if err != nil { + return nil, err + } + namespaceKey.NamespaceId = ns.GetId() + } + + resp, err := h.sdk.Namespaces.AssignPublicKeyToNamespace(ctx, &namespaces.AssignPublicKeyToNamespaceRequest{ + NamespaceKey: namespaceKey, + }) + if err != nil { + return nil, err + } + + return resp.GetNamespaceKey(), nil +} + +// RemoveKeyFromAttributeNamespace removes a KAS key from an attribute namespace +func (h *Handler) RemoveKeyFromAttributeNamespace(ctx context.Context, namespace, keyID string) error { + namespaceKey := &namespaces.NamespaceKey{ + KeyId: keyID, + NamespaceId: namespace, + } + + if _, err := uuid.Parse(namespace); err != nil { + ns, err := h.GetNamespace(ctx, namespace) + if err != nil { + return err + } + namespaceKey.NamespaceId = ns.GetId() + } + + _, err := h.sdk.Namespaces.RemovePublicKeyFromNamespace(ctx, &namespaces.RemovePublicKeyFromNamespaceRequest{ + NamespaceKey: namespaceKey, + }) + return err +} diff --git a/otdfctl/pkg/handlers/obligations.go b/otdfctl/pkg/handlers/obligations.go new file mode 100644 index 0000000000..59373ae22c --- /dev/null +++ b/otdfctl/pkg/handlers/obligations.go @@ -0,0 +1,233 @@ +package handlers + +import ( + "context" + + "github.com/google/uuid" + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/obligations" +) + +// ParseToIDFqnIdentifier creates an IdFqnIdentifier based on whether the input is a UUID or FQN +func ParseToIDFqnIdentifier(value string) *common.IdFqnIdentifier { + _, err := uuid.Parse(value) + if err != nil { + return &common.IdFqnIdentifier{Fqn: value} + } + return &common.IdFqnIdentifier{Id: value} +} + +// ParseToIDNameIdentifier creates an IdNameIdentifier based on whether the input is a UUID or name +func ParseToIDNameIdentifier(value string) *common.IdNameIdentifier { + _, err := uuid.Parse(value) + if err != nil { + return &common.IdNameIdentifier{Name: value} + } + return &common.IdNameIdentifier{Id: value} +} + +// +// Obligations +// + +func (h Handler) CreateObligation(ctx context.Context, namespace, name string, values []string, metadata *common.MetadataMutable) (*policy.Obligation, error) { + req := &obligations.CreateObligationRequest{ + Name: name, + Values: values, + Metadata: metadata, + } + req.NamespaceId, req.NamespaceFqn = getNamespaceIDAndFQN(namespace) + + resp, err := h.sdk.Obligations.CreateObligation(ctx, req) + if err != nil { + return nil, err + } + + return resp.GetObligation(), nil +} + +func (h Handler) GetObligation(ctx context.Context, id, fqn string) (*policy.Obligation, error) { + req := &obligations.GetObligationRequest{} + if id != "" { + req.Id = id + } else { + req.Fqn = fqn + } + + resp, err := h.sdk.Obligations.GetObligation(ctx, req) + if err != nil { + return nil, err + } + + return resp.GetObligation(), nil +} + +func (h Handler) ListObligations(ctx context.Context, limit, offset int32, namespace string) (*obligations.ListObligationsResponse, error) { + req := &obligations.ListObligationsRequest{ + Pagination: &policy.PageRequest{ + Limit: limit, + Offset: offset, + }, + } + if namespace != "" { + req.NamespaceId, req.NamespaceFqn = getNamespaceIDAndFQN(namespace) + } + return h.sdk.Obligations.ListObligations(ctx, req) +} + +func (h Handler) UpdateObligation(ctx context.Context, id, name string, metadata *common.MetadataMutable, behavior common.MetadataUpdateEnum) (*policy.Obligation, error) { + res, err := h.sdk.Obligations.UpdateObligation(ctx, &obligations.UpdateObligationRequest{ + Id: id, + Name: name, + Metadata: metadata, + MetadataUpdateBehavior: behavior, + }) + if err != nil { + return nil, err + } + + return res.GetObligation(), nil +} + +func (h Handler) DeleteObligation(ctx context.Context, id, fqn string) error { + req := &obligations.DeleteObligationRequest{} + if id != "" { + req.Id = id + } else { + req.Fqn = fqn + } + _, err := h.sdk.Obligations.DeleteObligation(ctx, req) + if err != nil { + return err + } + + return nil +} + +// +// Obligation Values +// + +func (h Handler) CreateObligationValue(ctx context.Context, obligation, value string, triggers []*obligations.ValueTriggerRequest, metadata *common.MetadataMutable) (*policy.ObligationValue, error) { + req := &obligations.CreateObligationValueRequest{ + Value: value, + Triggers: triggers, + Metadata: metadata, + } + + _, err := uuid.Parse(obligation) + if err != nil { + req.ObligationFqn = obligation + } else { + req.ObligationId = obligation + } + + resp, err := h.sdk.Obligations.CreateObligationValue(ctx, req) + if err != nil { + return nil, err + } + + return resp.GetValue(), nil +} + +func (h Handler) GetObligationValue(ctx context.Context, id, fqn string) (*policy.ObligationValue, error) { + req := &obligations.GetObligationValueRequest{} + if id != "" { + req.Id = id + } else { + req.Fqn = fqn + } + + resp, err := h.sdk.Obligations.GetObligationValue(ctx, req) + if err != nil { + return nil, err + } + + return resp.GetValue(), nil +} + +func (h Handler) UpdateObligationValue(ctx context.Context, id, value string, triggers []*obligations.ValueTriggerRequest, metadata *common.MetadataMutable, behavior common.MetadataUpdateEnum) (*policy.ObligationValue, error) { + res, err := h.sdk.Obligations.UpdateObligationValue(ctx, &obligations.UpdateObligationValueRequest{ + Id: id, + Value: value, + Triggers: triggers, + Metadata: metadata, + MetadataUpdateBehavior: behavior, + }) + if err != nil { + return nil, err + } + + return res.GetValue(), nil +} + +func (h Handler) DeleteObligationValue(ctx context.Context, id, fqn string) error { + req := &obligations.DeleteObligationValueRequest{} + if id != "" { + req.Id = id + } else { + req.Fqn = fqn + } + _, err := h.sdk.Obligations.DeleteObligationValue(ctx, req) + if err != nil { + return err + } + + return nil +} + +// ****** +// Obligation Triggers +// ****** +func (h Handler) CreateObligationTrigger(ctx context.Context, attributeValue, action, obligationValue, clientID string, metadata *common.MetadataMutable) (*policy.ObligationTrigger, error) { + req := &obligations.AddObligationTriggerRequest{ + Metadata: metadata, + } + + req.AttributeValue = ParseToIDFqnIdentifier(attributeValue) + req.Action = ParseToIDNameIdentifier(action) + req.ObligationValue = ParseToIDFqnIdentifier(obligationValue) + + if clientID != "" { + req.Context = &policy.RequestContext{ + Pep: &policy.PolicyEnforcementPoint{ + ClientId: clientID, + }, + } + } + + resp, err := h.sdk.Obligations.AddObligationTrigger(ctx, req) + if err != nil { + return nil, err + } + + return resp.GetTrigger(), nil +} + +func (h Handler) DeleteObligationTrigger(ctx context.Context, id string) (*policy.ObligationTrigger, error) { + req := &obligations.RemoveObligationTriggerRequest{ + Id: id, + } + resp, err := h.sdk.Obligations.RemoveObligationTrigger(ctx, req) + if err != nil { + return nil, err + } + + return resp.GetTrigger(), nil +} + +func (h Handler) ListObligationTriggers(ctx context.Context, namespace string, limit, offset int32) (*obligations.ListObligationTriggersResponse, error) { + req := &obligations.ListObligationTriggersRequest{ + Pagination: &policy.PageRequest{ + Limit: limit, + Offset: offset, + }, + } + + if namespace != "" { + req.NamespaceId, req.NamespaceFqn = getNamespaceIDAndFQN(namespace) + } + + return h.sdk.Obligations.ListObligationTriggers(ctx, req) +} diff --git a/otdfctl/pkg/handlers/provider-config.go b/otdfctl/pkg/handlers/provider-config.go new file mode 100644 index 0000000000..be25e3d4ed --- /dev/null +++ b/otdfctl/pkg/handlers/provider-config.go @@ -0,0 +1,95 @@ +package handlers + +import ( + "context" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/keymanagement" +) + +func (h Handler) CreateProviderConfig( + ctx context.Context, + name, manager string, + config []byte, + metadata *common.MetadataMutable, +) (*policy.KeyProviderConfig, error) { + req := keymanagement.CreateProviderConfigRequest{ + Name: name, + Manager: manager, + ConfigJson: config, + Metadata: metadata, + } + + resp, err := h.sdk.KeyManagement.CreateProviderConfig(ctx, &req) + if err != nil { + return nil, err + } + + return resp.GetProviderConfig(), nil +} + +func (h Handler) GetProviderConfig(ctx context.Context, id, name string) (*policy.KeyProviderConfig, error) { + req := keymanagement.GetProviderConfigRequest{} + if id != "" { + req.Identifier = &keymanagement.GetProviderConfigRequest_Id{ + Id: id, + } + } else if name != "" { + req.Identifier = &keymanagement.GetProviderConfigRequest_Name{ + Name: name, + } + } + + resp, err := h.sdk.KeyManagement.GetProviderConfig(ctx, &req) + if err != nil { + return nil, err + } + + return resp.GetProviderConfig(), nil +} + +func (h Handler) UpdateProviderConfig( + ctx context.Context, + id, name, manager string, + config []byte, + metadata *common.MetadataMutable, + behavior common.MetadataUpdateEnum, +) (*policy.KeyProviderConfig, error) { + req := keymanagement.UpdateProviderConfigRequest{ + Id: id, + Name: name, + Manager: manager, + ConfigJson: config, + Metadata: metadata, + MetadataUpdateBehavior: behavior, + } + + resp, err := h.sdk.KeyManagement.UpdateProviderConfig(ctx, &req) + if err != nil { + return nil, err + } + + return resp.GetProviderConfig(), nil +} + +func (h Handler) ListProviderConfigs(ctx context.Context, limit, offset int32) (*keymanagement.ListProviderConfigsResponse, error) { + req := keymanagement.ListProviderConfigsRequest{ + Pagination: &policy.PageRequest{ + Limit: limit, + Offset: offset, + }, + } + + return h.sdk.KeyManagement.ListProviderConfigs(ctx, &req) +} + +func (h *Handler) DeleteProviderConfig(ctx context.Context, id string) error { + _, err := h.sdk.KeyManagement.DeleteProviderConfig(ctx, &keymanagement.DeleteProviderConfigRequest{ + Id: id, + }) + if err != nil { + return err + } + return nil +} diff --git a/otdfctl/pkg/handlers/registeredResources.go b/otdfctl/pkg/handlers/registeredResources.go new file mode 100644 index 0000000000..cbbcc74e01 --- /dev/null +++ b/otdfctl/pkg/handlers/registeredResources.go @@ -0,0 +1,159 @@ +package handlers + +import ( + "context" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/registeredresources" +) + +// +// Registered Resources +// + +func (h Handler) CreateRegisteredResource(ctx context.Context, namespace, name string, values []string, metadata *common.MetadataMutable) (*policy.RegisteredResource, error) { + req := ®isteredresources.CreateRegisteredResourceRequest{ + Name: name, + Values: values, + Metadata: metadata, + } + + req.NamespaceId, req.NamespaceFqn = getNamespaceIDAndFQN(namespace) + + resp, err := h.sdk.RegisteredResources.CreateRegisteredResource(ctx, req) + if err != nil { + return nil, err + } + + return resp.GetResource(), nil +} + +func (h Handler) GetRegisteredResource(ctx context.Context, id, name, namespace string) (*policy.RegisteredResource, error) { + req := ®isteredresources.GetRegisteredResourceRequest{} + if id != "" { + req.Identifier = ®isteredresources.GetRegisteredResourceRequest_Id{ + Id: id, + } + } else { + req.Identifier = ®isteredresources.GetRegisteredResourceRequest_Name{ + Name: name, + } + } + if namespace != "" { + req.NamespaceId, req.NamespaceFqn = getNamespaceIDAndFQN(namespace) + } + + resp, err := h.sdk.RegisteredResources.GetRegisteredResource(ctx, req) + if err != nil { + return nil, err + } + + return resp.GetResource(), nil +} + +func (h Handler) ListRegisteredResources(ctx context.Context, limit, offset int32, namespace string) (*registeredresources.ListRegisteredResourcesResponse, error) { + req := ®isteredresources.ListRegisteredResourcesRequest{ + Pagination: &policy.PageRequest{ + Limit: limit, + Offset: offset, + }, + } + if namespace != "" { + req.NamespaceId, req.NamespaceFqn = getNamespaceIDAndFQN(namespace) + } + return h.sdk.RegisteredResources.ListRegisteredResources(ctx, req) +} + +func (h Handler) UpdateRegisteredResource(ctx context.Context, id, name string, metadata *common.MetadataMutable, behavior common.MetadataUpdateEnum) (*policy.RegisteredResource, error) { + _, err := h.sdk.RegisteredResources.UpdateRegisteredResource(ctx, ®isteredresources.UpdateRegisteredResourceRequest{ + Id: id, + Name: name, + Metadata: metadata, + MetadataUpdateBehavior: behavior, + }) + if err != nil { + return nil, err + } + + return h.GetRegisteredResource(ctx, id, "", "") +} + +func (h Handler) DeleteRegisteredResource(ctx context.Context, id string) error { + _, err := h.sdk.RegisteredResources.DeleteRegisteredResource(ctx, ®isteredresources.DeleteRegisteredResourceRequest{ + Id: id, + }) + + return err +} + +// +// Registered Resource Values +// + +func (h Handler) CreateRegisteredResourceValue(ctx context.Context, resourceID string, value string, actionAttributeValues []*registeredresources.ActionAttributeValue, metadata *common.MetadataMutable) (*policy.RegisteredResourceValue, error) { + resp, err := h.sdk.RegisteredResources.CreateRegisteredResourceValue(ctx, ®isteredresources.CreateRegisteredResourceValueRequest{ + ResourceId: resourceID, + Value: value, + ActionAttributeValues: actionAttributeValues, + Metadata: metadata, + }) + if err != nil { + return nil, err + } + + return resp.GetValue(), nil +} + +func (h Handler) GetRegisteredResourceValue(ctx context.Context, id, fqn string) (*policy.RegisteredResourceValue, error) { + req := ®isteredresources.GetRegisteredResourceValueRequest{} + if id != "" { + req.Identifier = ®isteredresources.GetRegisteredResourceValueRequest_Id{ + Id: id, + } + } else { + req.Identifier = ®isteredresources.GetRegisteredResourceValueRequest_Fqn{ + Fqn: fqn, + } + } + + resp, err := h.sdk.RegisteredResources.GetRegisteredResourceValue(ctx, req) + if err != nil { + return nil, err + } + + return resp.GetValue(), nil +} + +func (h Handler) ListRegisteredResourceValues(ctx context.Context, resourceID string, limit, offset int32) (*registeredresources.ListRegisteredResourceValuesResponse, error) { + return h.sdk.RegisteredResources.ListRegisteredResourceValues(ctx, ®isteredresources.ListRegisteredResourceValuesRequest{ + ResourceId: resourceID, + Pagination: &policy.PageRequest{ + Limit: limit, + Offset: offset, + }, + }) +} + +func (h Handler) UpdateRegisteredResourceValue(ctx context.Context, id, value string, actionAttributeValues []*registeredresources.ActionAttributeValue, metadata *common.MetadataMutable, behavior common.MetadataUpdateEnum) (*policy.RegisteredResourceValue, error) { + _, err := h.sdk.RegisteredResources.UpdateRegisteredResourceValue(ctx, ®isteredresources.UpdateRegisteredResourceValueRequest{ + Id: id, + Value: value, + ActionAttributeValues: actionAttributeValues, + Metadata: metadata, + MetadataUpdateBehavior: behavior, + }) + if err != nil { + return nil, err + } + + return h.GetRegisteredResourceValue(ctx, id, "") +} + +func (h Handler) DeleteRegisteredResourceValue(ctx context.Context, id string) error { + _, err := h.sdk.RegisteredResources.DeleteRegisteredResourceValue(ctx, ®isteredresources.DeleteRegisteredResourceValueRequest{ + Id: id, + }) + + return err +} diff --git a/otdfctl/pkg/handlers/resourceMappingGroups.go b/otdfctl/pkg/handlers/resourceMappingGroups.go new file mode 100644 index 0000000000..499df96a00 --- /dev/null +++ b/otdfctl/pkg/handlers/resourceMappingGroups.go @@ -0,0 +1,71 @@ +package handlers + +import ( + "context" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/resourcemapping" +) + +// Creates and returns the created resource mapping +func (h *Handler) CreateResourceMappingGroup(ctx context.Context, namespaceID string, name string, metadata *common.MetadataMutable) (*policy.ResourceMappingGroup, error) { + res, err := h.sdk.ResourceMapping.CreateResourceMappingGroup(ctx, &resourcemapping.CreateResourceMappingGroupRequest{ + NamespaceId: namespaceID, + Name: name, + Metadata: metadata, + }) + if err != nil { + return nil, err + } + + return h.GetResourceMappingGroup(ctx, res.GetResourceMappingGroup().GetId()) +} + +func (h *Handler) GetResourceMappingGroup(ctx context.Context, id string) (*policy.ResourceMappingGroup, error) { + res, err := h.sdk.ResourceMapping.GetResourceMappingGroup(ctx, &resourcemapping.GetResourceMappingGroupRequest{ + Id: id, + }) + if err != nil { + return nil, err + } + + return res.GetResourceMappingGroup(), nil +} + +func (h *Handler) ListResourceMappingGroups(ctx context.Context, limit, offset int32) (*resourcemapping.ListResourceMappingGroupsResponse, error) { + return h.sdk.ResourceMapping.ListResourceMappingGroups(ctx, &resourcemapping.ListResourceMappingGroupsRequest{ + Pagination: &policy.PageRequest{ + Limit: limit, + Offset: offset, + }, + }) +} + +// TODO: verify updation behavior +// Updates and returns the updated resource mapping +func (h *Handler) UpdateResourceMappingGroup(ctx context.Context, id string, namespaceID string, name string, metadata *common.MetadataMutable, behavior common.MetadataUpdateEnum) (*policy.ResourceMappingGroup, error) { + _, err := h.sdk.ResourceMapping.UpdateResourceMappingGroup(ctx, &resourcemapping.UpdateResourceMappingGroupRequest{ + Id: id, + NamespaceId: namespaceID, + Name: name, + Metadata: metadata, + MetadataUpdateBehavior: behavior, + }) + if err != nil { + return nil, err + } + + return h.GetResourceMappingGroup(ctx, id) +} + +func (h *Handler) DeleteResourceMappingGroup(ctx context.Context, id string) (*policy.ResourceMappingGroup, error) { + resp, err := h.sdk.ResourceMapping.DeleteResourceMappingGroup(ctx, &resourcemapping.DeleteResourceMappingGroupRequest{ + Id: id, + }) + if err != nil { + return nil, err + } + + return resp.GetResourceMappingGroup(), nil +} diff --git a/otdfctl/pkg/handlers/resourceMappings.go b/otdfctl/pkg/handlers/resourceMappings.go new file mode 100644 index 0000000000..ccb80d72bf --- /dev/null +++ b/otdfctl/pkg/handlers/resourceMappings.go @@ -0,0 +1,73 @@ +package handlers + +import ( + "context" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/resourcemapping" +) + +// Creates and returns the created resource mapping +func (h *Handler) CreateResourceMapping(attributeID string, terms []string, grpID string, metadata *common.MetadataMutable) (*policy.ResourceMapping, error) { + res, err := h.sdk.ResourceMapping.CreateResourceMapping(context.Background(), &resourcemapping.CreateResourceMappingRequest{ + AttributeValueId: attributeID, + GroupId: grpID, + Terms: terms, + Metadata: metadata, + }) + if err != nil { + return nil, err + } + + return h.GetResourceMapping(res.GetResourceMapping().GetId()) +} + +func (h *Handler) GetResourceMapping(id string) (*policy.ResourceMapping, error) { + res, err := h.sdk.ResourceMapping.GetResourceMapping(context.Background(), &resourcemapping.GetResourceMappingRequest{ + Id: id, + }) + if err != nil { + return nil, err + } + + return res.GetResourceMapping(), nil +} + +func (h *Handler) ListResourceMappings(ctx context.Context, limit, offset int32) (*resourcemapping.ListResourceMappingsResponse, error) { + return h.sdk.ResourceMapping.ListResourceMappings(ctx, &resourcemapping.ListResourceMappingsRequest{ + Pagination: &policy.PageRequest{ + Limit: limit, + Offset: offset, + }, + }) +} + +// TODO: verify updation behavior +// Updates and returns the updated resource mapping +func (h *Handler) UpdateResourceMapping(id string, attrValueID string, grpID string, terms []string, metadata *common.MetadataMutable, behavior common.MetadataUpdateEnum) (*policy.ResourceMapping, error) { + _, err := h.sdk.ResourceMapping.UpdateResourceMapping(context.Background(), &resourcemapping.UpdateResourceMappingRequest{ + Id: id, + AttributeValueId: attrValueID, + Terms: terms, + GroupId: grpID, + Metadata: metadata, + MetadataUpdateBehavior: behavior, + }) + if err != nil { + return nil, err + } + + return h.GetResourceMapping(id) +} + +func (h *Handler) DeleteResourceMapping(id string) (*policy.ResourceMapping, error) { + resp, err := h.sdk.ResourceMapping.DeleteResourceMapping(context.Background(), &resourcemapping.DeleteResourceMappingRequest{ + Id: id, + }) + if err != nil { + return nil, err + } + + return resp.GetResourceMapping(), nil +} diff --git a/otdfctl/pkg/handlers/sdk.go b/otdfctl/pkg/handlers/sdk.go new file mode 100644 index 0000000000..2c1e0f7871 --- /dev/null +++ b/otdfctl/pkg/handlers/sdk.go @@ -0,0 +1,152 @@ +package handlers + +import ( + "errors" + "log/slog" + + "github.com/opentdf/platform/otdfctl/pkg/auth" + "github.com/opentdf/platform/otdfctl/pkg/profiles" + "github.com/opentdf/platform/otdfctl/pkg/utils" + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/sdk" +) + +var ( + SDK *sdk.SDK + + ErrUnauthenticated = errors.New("unauthenticated") +) + +type Handler struct { + sdk *sdk.SDK + platformEndpoint string +} + +type handlerOpts struct { + endpoint string + TLSNoVerify bool + + profile *profiles.OtdfctlProfileStore + + sdkOpts []sdk.Option +} + +type handlerOptsFunc func(handlerOpts) handlerOpts + +func WithEndpoint(endpoint string, tlsNoVerify bool) handlerOptsFunc { + return func(c handlerOpts) handlerOpts { + c.endpoint = endpoint + c.TLSNoVerify = tlsNoVerify + return c + } +} + +func WithProfile(profile *profiles.OtdfctlProfileStore) handlerOptsFunc { + return func(c handlerOpts) handlerOpts { + c.profile = profile + c.endpoint = profile.GetEndpoint() + c.TLSNoVerify = profile.GetTLSNoVerify() + + // get sdk opts + opts, err := auth.GetSDKAuthOptionFromProfile(profile) + if err != nil { + return c + } + c.sdkOpts = append(c.sdkOpts, opts) + + return c + } +} + +func WithSDKOpts(opts ...sdk.Option) handlerOptsFunc { + return func(c handlerOpts) handlerOpts { + c.sdkOpts = opts + return c + } +} + +// Creates a new handler wrapping the SDK, which is authenticated through the cached client-credentials flow tokens +func New(opts ...handlerOptsFunc) (Handler, error) { + var o handlerOpts + for _, f := range opts { + o = f(o) + } + + u, err := utils.NormalizeEndpoint(o.endpoint) + if err != nil { + return Handler{}, err + } + + // get auth + authSDKOpt, err := auth.GetSDKAuthOptionFromProfile(o.profile) + if err != nil { + return Handler{}, err + } + + defaultSDKOpts := []sdk.Option{ + authSDKOpt, + sdk.WithConnectionValidation(), + sdk.WithLogger(slog.Default()), + } + if o.TLSNoVerify { + defaultSDKOpts = append(defaultSDKOpts, sdk.WithInsecureSkipVerifyConn()) + } + + if u.Scheme == "http" { + defaultSDKOpts = append(defaultSDKOpts, sdk.WithInsecurePlaintextConn()) + } + o.sdkOpts = append(defaultSDKOpts, o.sdkOpts...) + + s, err := sdk.New(u.String(), o.sdkOpts...) + if err != nil { + return Handler{}, err + } + + return Handler{ + sdk: s, + platformEndpoint: o.endpoint, + }, nil +} + +func (h Handler) Close() error { + return h.sdk.Close() +} + +func (h Handler) Direct() *sdk.SDK { + return h.sdk +} + +// Replace all labels in the metadata +func (h Handler) WithReplaceLabelsMetadata(metadata *common.MetadataMutable, labels map[string]string) func(*common.MetadataMutable) *common.MetadataMutable { + return func(*common.MetadataMutable) *common.MetadataMutable { + nextMetadata := &common.MetadataMutable{ + Labels: labels, + } + return nextMetadata + } +} + +// Append a label to the metadata +func (h Handler) WithLabelMetadata(metadata *common.MetadataMutable, key, value string) func(*common.MetadataMutable) *common.MetadataMutable { + return func(*common.MetadataMutable) *common.MetadataMutable { + labels := metadata.GetLabels() + labels[key] = value + nextMetadata := &common.MetadataMutable{ + Labels: labels, + } + return nextMetadata + } +} + +// func buildMetadata(metadata *common.MetadataMutable, fns ...func(*common.MetadataMutable) *common.MetadataMutable) *common.MetadataMutable { +// if metadata == nil { +// metadata = &common.MetadataMutable{} +// } +// if len(fns) == 0 { +// return metadata +// } +// for _, fn := range fns { +// metadata = fn(metadata) +// } +// return metadata +// } diff --git a/otdfctl/pkg/handlers/selectors.go b/otdfctl/pkg/handlers/selectors.go new file mode 100644 index 0000000000..70f3871eb6 --- /dev/null +++ b/otdfctl/pkg/handlers/selectors.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/golang-jwt/jwt/v5" + flat "github.com/opentdf/platform/lib/flattening" +) + +func ParseSubjectString(subject string) (map[string]interface{}, error) { + var value map[string]interface{} + //nolint:errcheck // if fails to unmarshal, may be a JWT, so swallow the error + json.Unmarshal([]byte(subject), &value) + + if value == nil { + token, _, err := new(jwt.Parser).ParseUnverified(subject, jwt.MapClaims{}) + if err != nil { + return nil, fmt.Errorf("failed to flatten subject [%v]: %w", subject, err) + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok { + value = claims + } else { + return nil, errors.New("failed to get claims from subject JWT token") + } + } + + if value == nil { + return nil, errors.New("invalid subject context type. Must be of type: [json, jwt]") + } + return value, nil +} + +func FlattenSubjectContext(subject string) ([]flat.Item, error) { + value, err := ParseSubjectString(subject) + if err != nil { + return nil, fmt.Errorf("failed to parse subject string into JSON or JWT [%s]: %w", subject, err) + } + + flattened, err := flat.Flatten(value) + if err != nil { + return nil, fmt.Errorf("failed to flatten subject [%v]: %w", subject, err) + } + + return flattened.Items, nil +} diff --git a/otdfctl/pkg/handlers/subjectConditionSets.go b/otdfctl/pkg/handlers/subjectConditionSets.go new file mode 100644 index 0000000000..76ec21c57c --- /dev/null +++ b/otdfctl/pkg/handlers/subjectConditionSets.go @@ -0,0 +1,76 @@ +package handlers + +import ( + "context" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/subjectmapping" +) + +func (h Handler) GetSubjectConditionSet(ctx context.Context, id string) (*policy.SubjectConditionSet, error) { + resp, err := h.sdk.SubjectMapping.GetSubjectConditionSet(ctx, &subjectmapping.GetSubjectConditionSetRequest{ + Id: id, + }) + if err != nil { + return nil, err + } + + return resp.GetSubjectConditionSet(), nil +} + +func (h Handler) ListSubjectConditionSets(ctx context.Context, limit, offset int32, namespace string) (*subjectmapping.ListSubjectConditionSetsResponse, error) { + req := &subjectmapping.ListSubjectConditionSetsRequest{ + Pagination: &policy.PageRequest{ + Limit: limit, + Offset: offset, + }, + } + req.NamespaceId, req.NamespaceFqn = getNamespaceIDAndFQN(namespace) + return h.sdk.SubjectMapping.ListSubjectConditionSets(ctx, req) +} + +// Creates and returns the created subject condition set +func (h Handler) CreateSubjectConditionSet(ctx context.Context, ss []*policy.SubjectSet, metadata *common.MetadataMutable, namespace string) (*policy.SubjectConditionSet, error) { + req := &subjectmapping.CreateSubjectConditionSetRequest{ + SubjectConditionSet: &subjectmapping.SubjectConditionSetCreate{ + SubjectSets: ss, + Metadata: metadata, + }, + } + req.NamespaceId, req.NamespaceFqn = getNamespaceIDAndFQN(namespace) + resp, err := h.sdk.SubjectMapping.CreateSubjectConditionSet(ctx, req) + if err != nil { + return nil, err + } + return h.GetSubjectConditionSet(ctx, resp.GetSubjectConditionSet().GetId()) +} + +// Updates and returns the updated subject condition set +func (h Handler) UpdateSubjectConditionSet(ctx context.Context, id string, ss []*policy.SubjectSet, metadata *common.MetadataMutable, behavior common.MetadataUpdateEnum) (*policy.SubjectConditionSet, error) { + _, err := h.sdk.SubjectMapping.UpdateSubjectConditionSet(ctx, &subjectmapping.UpdateSubjectConditionSetRequest{ + Id: id, + SubjectSets: ss, + Metadata: metadata, + MetadataUpdateBehavior: behavior, + }) + if err != nil { + return nil, err + } + return h.GetSubjectConditionSet(ctx, id) +} + +func (h Handler) DeleteSubjectConditionSet(ctx context.Context, id string) error { + _, err := h.sdk.SubjectMapping.DeleteSubjectConditionSet(ctx, &subjectmapping.DeleteSubjectConditionSetRequest{ + Id: id, + }) + return err +} + +func (h Handler) PruneSubjectConditionSets(ctx context.Context) ([]*policy.SubjectConditionSet, error) { + rsp, err := h.sdk.SubjectMapping.DeleteAllUnmappedSubjectConditionSets(ctx, &subjectmapping.DeleteAllUnmappedSubjectConditionSetsRequest{}) + if err != nil { + return nil, err + } + return rsp.GetSubjectConditionSets(), nil +} diff --git a/otdfctl/pkg/handlers/subjectmappings.go b/otdfctl/pkg/handlers/subjectmappings.go new file mode 100644 index 0000000000..c93431aa25 --- /dev/null +++ b/otdfctl/pkg/handlers/subjectmappings.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "context" + + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/opentdf/platform/protocol/go/policy/subjectmapping" +) + +const ( + SubjectMappingOperatorIn = "IN" + SubjectMappingOperatorNotIn = "NOT_IN" + SubjectMappingOperatorInContains = "IN_CONTAINS" + SubjectMappingOperatorUnspecified = "UNSPECIFIED" +) + +var SubjectMappingOperatorEnumChoices = []string{SubjectMappingOperatorIn, SubjectMappingOperatorNotIn, SubjectMappingOperatorUnspecified} + +func (h Handler) GetSubjectMapping(ctx context.Context, id string) (*policy.SubjectMapping, error) { + resp, err := h.sdk.SubjectMapping.GetSubjectMapping(ctx, &subjectmapping.GetSubjectMappingRequest{ + Id: id, + }) + return resp.GetSubjectMapping(), err +} + +func (h Handler) ListSubjectMappings(ctx context.Context, limit, offset int32, namespace string) (*subjectmapping.ListSubjectMappingsResponse, error) { + req := &subjectmapping.ListSubjectMappingsRequest{ + Pagination: &policy.PageRequest{ + Limit: limit, + Offset: offset, + }, + } + req.NamespaceId, req.NamespaceFqn = getNamespaceIDAndFQN(namespace) + return h.sdk.SubjectMapping.ListSubjectMappings(ctx, req) +} + +// Creates and returns the created subject mapping +func (h Handler) CreateNewSubjectMapping(ctx context.Context, attrValID string, actions []*policy.Action, existingSCSId string, newScs *subjectmapping.SubjectConditionSetCreate, m *common.MetadataMutable, namespace string) (*policy.SubjectMapping, error) { + req := &subjectmapping.CreateSubjectMappingRequest{ + AttributeValueId: attrValID, + Actions: actions, + ExistingSubjectConditionSetId: existingSCSId, + NewSubjectConditionSet: newScs, + Metadata: m, + } + req.NamespaceId, req.NamespaceFqn = getNamespaceIDAndFQN(namespace) + resp, err := h.sdk.SubjectMapping.CreateSubjectMapping(ctx, req) + if err != nil { + return nil, err + } + return h.GetSubjectMapping(ctx, resp.GetSubjectMapping().GetId()) +} + +// Updates and returns the updated subject mapping +func (h Handler) UpdateSubjectMapping(ctx context.Context, id string, updatedSCSId string, updatedActions []*policy.Action, metadata *common.MetadataMutable, metadataBehavior common.MetadataUpdateEnum) (*policy.SubjectMapping, error) { + _, err := h.sdk.SubjectMapping.UpdateSubjectMapping(ctx, &subjectmapping.UpdateSubjectMappingRequest{ + Id: id, + SubjectConditionSetId: updatedSCSId, + Actions: updatedActions, + MetadataUpdateBehavior: metadataBehavior, + Metadata: metadata, + }) + if err != nil { + return nil, err + } + return h.GetSubjectMapping(ctx, id) +} + +func (h Handler) DeleteSubjectMapping(ctx context.Context, id string) (*policy.SubjectMapping, error) { + resp, err := h.sdk.SubjectMapping.DeleteSubjectMapping(ctx, &subjectmapping.DeleteSubjectMappingRequest{ + Id: id, + }) + return resp.GetSubjectMapping(), err +} + +func (h Handler) MatchSubjectMappings(ctx context.Context, selectors []string) ([]*policy.SubjectMapping, error) { + subjectProperties := make([]*policy.SubjectProperty, len(selectors)) + for i, selector := range selectors { + subjectProperties[i] = &policy.SubjectProperty{ + ExternalSelectorValue: selector, + } + } + resp, err := h.sdk.SubjectMapping.MatchSubjectMappings(ctx, &subjectmapping.MatchSubjectMappingsRequest{ + SubjectProperties: subjectProperties, + }) + return resp.GetSubjectMappings(), err +} + +func GetSubjectMappingOperatorFromChoice(readable string) policy.SubjectMappingOperatorEnum { + switch readable { + case SubjectMappingOperatorIn: + return policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN + case SubjectMappingOperatorNotIn: + return policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_NOT_IN + case SubjectMappingOperatorInContains: + return policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN_CONTAINS + case SubjectMappingOperatorUnspecified: + return policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_UNSPECIFIED + default: + return policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_UNSPECIFIED + } +} + +func GetSubjectMappingOperatorChoiceFromEnum(enum policy.SubjectMappingOperatorEnum) string { + switch enum { + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN: + return SubjectMappingOperatorIn + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_NOT_IN: + return SubjectMappingOperatorNotIn + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_IN_CONTAINS: + return SubjectMappingOperatorInContains + case policy.SubjectMappingOperatorEnum_SUBJECT_MAPPING_OPERATOR_ENUM_UNSPECIFIED: + return SubjectMappingOperatorUnspecified + default: + return SubjectMappingOperatorUnspecified + } +} diff --git a/otdfctl/pkg/handlers/tdf.go b/otdfctl/pkg/handlers/tdf.go new file mode 100644 index 0000000000..d8a5b0d69f --- /dev/null +++ b/otdfctl/pkg/handlers/tdf.go @@ -0,0 +1,278 @@ +package handlers + +import ( + "bytes" + "context" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "log/slog" + "strings" + + "github.com/opentdf/platform/lib/ocrypto" + "github.com/opentdf/platform/otdfctl/pkg/tdf" + "github.com/opentdf/platform/otdfctl/pkg/utils" + "github.com/opentdf/platform/sdk" +) + +var ( + ErrTDFInspectFailNotValidTDF = errors.New("file or input is not a valid TDF") + ErrTDFInspectFailNotInspectable = errors.New("file or input is not inspectable") + ErrTDFUnableToReadAttributes = errors.New("unable to read attributes from TDF") + ErrTDFUnableToReadUnencryptedMetadata = errors.New("unable to read unencrypted metadata from TDF") + ErrTDFUnableToReadAssertions = errors.New("unable to read assertions") + ErrTDFUnableToReadAssertionVerificationKeys = errors.New("unable to read assertion verification keys") +) + +const ( + MaxAssertionsFileSize = int64(5 * 1024 * 1024) // 5MB +) + +type TDFInspect struct { + ZTDFManifest *sdk.Manifest + Attributes []string + UnencryptedMetadata []byte +} + +func (h Handler) EncryptBytes( + tdfType string, + unencrypted []byte, + attrValues []string, + mimeType string, + kasURLPath string, + assertions string, + wrappingKeyAlgorithm ocrypto.KeyType, + targetMode string, +) (*bytes.Buffer, error) { + var encrypted []byte + enc := bytes.NewBuffer(encrypted) + + switch tdfType { + // Encrypt the data as a ZTDF + case "", tdf.TypeTDF3, tdf.TypeZTDF: + opts := []sdk.TDFOption{ + sdk.WithDataAttributes(attrValues...), + sdk.WithKasInformation(sdk.KASInfo{ + URL: h.platformEndpoint + kasURLPath, + }), + sdk.WithMimeType(mimeType), + sdk.WithWrappingKeyAlg(wrappingKeyAlgorithm), //nolint:staticcheck // SDK option is deprecated but no replacement is available in this SDK version. + } + + var assertionConfigs []sdk.AssertionConfig + //nolint:nestif // nested its mainly for error catching and handling case of string vs file + if assertions != "" { + err := json.Unmarshal([]byte(assertions), &assertionConfigs) + if err != nil { + // if unable to marshal to json, interpret as file string and try to read from file + assertionBytes, err := utils.ReadBytesFromFile(assertions, MaxAssertionsFileSize) + if err != nil { + return nil, fmt.Errorf("unable to read assertions file: %w", err) + } + err = json.Unmarshal(assertionBytes, &assertionConfigs) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal assertions json: %w", err) + } + } + for i, config := range assertionConfigs { + if !config.SigningKey.IsEmpty() { + correctedKey, err := correctKeyType(config.SigningKey, false) + if err != nil { + return nil, fmt.Errorf("error with assertion signing key: %w", err) + } + assertionConfigs[i].SigningKey.Key = correctedKey + } + } + opts = append(opts, sdk.WithAssertions(assertionConfigs...)) + } + + if targetMode != "" { + opts = append(opts, sdk.WithTargetMode(targetMode)) + } + + _, err := h.sdk.CreateTDF(enc, bytes.NewReader(unencrypted), opts...) + return enc, err + default: + return nil, errors.New("unknown TDF type") + } +} + +func (h Handler) DecryptBytes( + ctx context.Context, + toDecrypt []byte, + assertionVerificationKeysFile string, + disableAssertionCheck bool, + sessionKeyAlgorithm ocrypto.KeyType, + kasAllowList []string, + ignoreAllowlist bool, + fulfillableObligations []string, +) (*bytes.Buffer, error) { + out := &bytes.Buffer{} + pt := io.Writer(out) + ec := bytes.NewReader(toDecrypt) + switch sdk.GetTdfType(ec) { + case sdk.Standard: + opts := []sdk.TDFReaderOption{ + sdk.WithDisableAssertionVerification(disableAssertionCheck), + sdk.WithSessionKeyType(sessionKeyAlgorithm), + sdk.WithIgnoreAllowlist(ignoreAllowlist), + sdk.WithTDFFulfillableObligationFQNs(fulfillableObligations), + } + if kasAllowList != nil { + opts = append(opts, sdk.WithKasAllowlist(kasAllowList)) + } + var assertionVerificationKeys sdk.AssertionVerificationKeys + if assertionVerificationKeysFile != "" { + // read the file + assertionVerificationBytes, err := utils.ReadBytesFromFile(assertionVerificationKeysFile, MaxAssertionsFileSize) + if err != nil { + return nil, fmt.Errorf("unable to read assertions verification keys file: %w", err) + } + err = json.Unmarshal(assertionVerificationBytes, &assertionVerificationKeys) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal assertion verification keys json: %w", err) + } + for assertionName, key := range assertionVerificationKeys.Keys { + correctedKey, err := correctKeyType(key, true) + if err != nil { + return nil, fmt.Errorf("error with assertion signing key: %w", err) + } + assertionVerificationKeys.Keys[assertionName] = sdk.AssertionKey{Alg: key.Alg, Key: correctedKey} + } + opts = append(opts, sdk.WithAssertionVerificationKeys(assertionVerificationKeys)) + } + r, err := h.sdk.LoadTDF(ec, opts...) + if err != nil { + return nil, err + } + //nolint:errorlint // callers intended to test error equality directly + if _, err = io.Copy(pt, r); err != nil && err != io.EOF { + return nil, formatDecryptError(ctx, r.Obligations, err) + } + case sdk.Invalid: + return nil, errors.New("invalid TDF") + default: + return nil, errors.New("unknown TDF type") + } + return out, nil +} + +func (h Handler) InspectTDF(toInspect []byte) (TDFInspect, []error) { + b := bytes.NewReader(toInspect) + switch sdk.GetTdfType(b) { + case sdk.Standard: + // grouping errors so we don't impact the piping of the data + errs := []error{} + + tdfreader, err := h.sdk.LoadTDF(bytes.NewReader(toInspect)) + if err != nil { + if strings.Contains(err.Error(), "zip: not a valid zip file") { + return TDFInspect{}, []error{ErrTDFInspectFailNotInspectable} + } + return TDFInspect{}, []error{errors.Join(ErrTDFInspectFailNotValidTDF, err)} + } + + attributes, err := tdfreader.DataAttributes() + if err != nil { + errs = append(errs, errors.Join(ErrTDFUnableToReadAttributes, err)) + } + + unencryptedMetadata, err := tdfreader.UnencryptedMetadata() + if err != nil { + errs = append(errs, errors.Join(ErrTDFUnableToReadUnencryptedMetadata, err)) + } + + m := tdfreader.Manifest() + return TDFInspect{ + ZTDFManifest: &m, + Attributes: attributes, + UnencryptedMetadata: unencryptedMetadata, + }, errs + case sdk.Invalid: + return TDFInspect{}, []error{ErrTDFInspectFailNotValidTDF} + default: + return TDFInspect{}, []error{errors.New("tdf format unrecognized")} + } +} + +func correctKeyType(assertionKey sdk.AssertionKey, public bool) (interface{}, error) { + strKey, ok := assertionKey.Key.(string) + if !ok { + return nil, errors.New("unable to convert assertion key to string") + } + + switch assertionKey.Alg { + case sdk.AssertionKeyAlgHS256: + // convert the hs256 key to []byte + return []byte(strKey), nil + case sdk.AssertionKeyAlgRS256: + // Decode the PEM block + block, _ := pem.Decode([]byte(strKey)) + if block == nil { + return nil, errors.New("failed to decode PEM block") + } + + // Check the block type and parse accordingly + var privateKey *rsa.PrivateKey + var publicKey *rsa.PublicKey + var err error + switch block.Type { + case "RSA PRIVATE KEY": + privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes) + publicKey = &privateKey.PublicKey + case "PRIVATE KEY": + parsedKey, parseErr := x509.ParsePKCS8PrivateKey(block.Bytes) + if parseErr != nil { + return nil, fmt.Errorf("failed to parse PKCS#8 private key: %w", parseErr) + } + privateKey, ok = parsedKey.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("parsed key is not an RSA private key") + } + publicKey = &privateKey.PublicKey + case "RSA PUBLIC KEY": + publicKey, err = x509.ParsePKCS1PublicKey(block.Bytes) + case "PUBLIC KEY": + parsedKey, parseErr := x509.ParsePKIXPublicKey(block.Bytes) + if parseErr != nil { + return nil, fmt.Errorf("failed to parse PKIX public key: %w", parseErr) + } + publicKey, ok = parsedKey.(*rsa.PublicKey) + if !ok { + return nil, errors.New("parsed key is not an RSA public key") + } + default: + return nil, fmt.Errorf("unsupported key type: %s", block.Type) + } + + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + if public { + return publicKey, nil + } + return privateKey, nil + } + return nil, fmt.Errorf("unsupported signing key alg: %v", assertionKey.Alg) +} + +func formatDecryptError(ctx context.Context, getObligations func(ctx context.Context) (sdk.RequiredObligations, error), err error) error { + // Avoid calling Rewrap again, if the error is a 500 error from KAS + if errors.Is(err, sdk.ErrRewrapForbidden) { + obligations, oblErr := getObligations(ctx) + if oblErr != nil { + slog.DebugContext(ctx, "failed to get obligations after decrypt, obligations must not be cached", + slog.Any("error", oblErr), + ) + } + + if len(obligations.FQNs) > 0 { + err = errors.Join(err, fmt.Errorf("\nrequired obligations: %v", obligations.FQNs)) + } + } + return err +} diff --git a/otdfctl/pkg/man/docflags.go b/otdfctl/pkg/man/docflags.go new file mode 100644 index 0000000000..4af5388403 --- /dev/null +++ b/otdfctl/pkg/man/docflags.go @@ -0,0 +1,31 @@ +package man + +import ( + "fmt" + + "github.com/opentdf/platform/otdfctl/pkg/cli" +) + +type DocFlag struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Shorthand string `yaml:"shorthand"` + Default string `yaml:"default"` + Enum []string `yaml:"enum"` +} + +func (d *Doc) GetDocFlag(name string) DocFlag { + for _, f := range d.DocFlags { + if f.Name == name { + if len(f.Enum) > 0 { + f.Description = fmt.Sprintf("%s %s", f.Description, cli.CommaSeparated(f.Enum)) + } + return f + } + } + panic(fmt.Sprintf("No doc flag found for name, %s for command %s", name, d.Use)) +} + +func (f DocFlag) DefaultAsBool() bool { + return f.Default == "true" +} diff --git a/otdfctl/pkg/man/man.go b/otdfctl/pkg/man/man.go new file mode 100644 index 0000000000..c6d36c53b3 --- /dev/null +++ b/otdfctl/pkg/man/man.go @@ -0,0 +1,260 @@ +package man + +import ( + "embed" + "errors" + "fmt" + "io/fs" + "log/slog" + "strings" + + "github.com/adrg/frontmatter" + docsEmbed "github.com/opentdf/platform/otdfctl/docs" + "github.com/spf13/cobra" +) + +var Docs Manual + +type CommandOpts func(d *Doc) + +type Doc struct { + cobra.Command + DocFlags []DocFlag + DocSubcommands []*Doc +} + +// deprecated +func (d *Doc) GetShort(subCmds []string) string { + return fmt.Sprintf("%s [%s]", d.Short, strings.Join(subCmds, ", ")) +} + +func (d *Doc) AddSubcommands(subCmds ...*Doc) { + cmds := make([]string, 0) + for _, c := range subCmds { + cmds = append(cmds, c.Use) + d.DocSubcommands = append(d.DocSubcommands, c) + d.AddCommand(&c.Command) + } + d.Short = d.GetShort(cmds) +} + +func WithSubcommands(subCmds ...*Doc) CommandOpts { + return func(d *Doc) { + for _, c := range subCmds { + d.DocSubcommands = append(d.DocSubcommands, c) + d.AddCommand(&c.Command) + } + } +} + +func WithRun(f func(cmd *cobra.Command, args []string)) CommandOpts { + return func(d *Doc) { + d.Run = f + } +} + +// Hide any global or persisent flags from parent commands on the given command +func WithHiddenFlags(flags ...string) CommandOpts { + return func(d *Doc) { + // to hide root global flags, must set a custom help func that hides then calls the parent help func + d.SetHelpFunc(func(command *cobra.Command, strings []string) { + for _, f := range flags { + //nolint:errcheck // hidden flag err is not a concern + command.Flags().MarkHidden(f) + } + d.Parent().HelpFunc()(command, strings) + }) + } +} + +type Manual struct { + lang string + Docs map[string]*Doc + En map[string]*Doc + Fr map[string]*Doc +} + +func (m *Manual) SetLang(l string) { + switch l { + case "en", "fr": + m.lang = l + default: + panic("Unknown language: " + l) + } +} + +func (m Manual) GetDoc(cmd string) *Doc { + if m.lang != "en" { + //nolint:gocritic // other languages may be supported + switch m.lang { + case "fr": + if _, ok := m.Fr[cmd]; ok { + return m.Fr[cmd] + } + // if no doc found in french, fallback to english + slog.Debug("no doc found for cmd, falling back to english", + slog.String("cmd", cmd), + slog.String("lang", m.lang), + ) + } + } + + if _, ok := m.En[cmd]; !ok { + panic("No doc found for cmd, " + cmd) + } + + return m.En[cmd] +} + +func (m Manual) GetCommand(cmd string, opts ...CommandOpts) *Doc { + d := m.GetDoc(cmd) + + for _, opt := range opts { + opt(d) + } + + if len(d.DocSubcommands) > 0 { + s := make([]string, 0) + for _, c := range d.DocSubcommands { + s = append(s, c.Use) + } + d.Short = d.GetShort(s) + } + + return d +} + +//nolint:mnd,gocritic // allow file separator counts to be hardcoded +func ProcessEmbeddedDocs(manFiles embed.FS) { + err := fs.WalkDir(manFiles, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + // extract language from filename + p := strings.Split(d.Name(), ".") + cmd := p[0] + lang := "en" + + // check if file is a markdown file + if p[len(p)-1] != "md" { + return nil + } else if len(p) < 2 || len(p) > 3 { + return nil + } else if len(p) == 3 { + lang = p[1] + } + + // remove extension and extract command from path + p = strings.Split(path, "/") + // remove leading and trailing slashes + p = p[1 : len(p)-1] + // if the last element is not _index, it is a subcommand + if cmd != "_index" { + p = append(p, cmd) + } + cmd = strings.Join(p, "/") + + if cmd == "" { + cmd = "" + } + + slog.Debug("found doc", + slog.String("cmd", cmd), + slog.String("lang", lang), + ) + c, err := manFiles.ReadFile(path) + if err != nil { + return fmt.Errorf("could not read file, %s: %s ", path, err.Error()) + } + + doc, err := ProcessDoc(string(c)) + if err != nil { + return fmt.Errorf("could not process doc, %s: %s", path, err.Error()) + } + + slog.Debug("adding doc", + slog.String("cmd", cmd), + slog.String("lang", lang), + ) + switch lang { + case "fr": + Docs.Fr[cmd] = doc + case "en": + Docs.En[cmd] = doc + default: + + return fmt.Errorf("unknown language [%s]", lang) + } + return nil + }) + if err != nil { + panic("Could not read embedded files: " + err.Error()) + } +} + +func init() { + slog.Debug("loading docs from embed") + Docs = Manual{ + Docs: make(map[string]*Doc), + En: make(map[string]*Doc), + Fr: make(map[string]*Doc), + } + + ProcessEmbeddedDocs(docsEmbed.ManFiles) +} + +func ProcessDoc(doc string) (*Doc, error) { + if len(doc) == 0 { + return nil, errors.New("empty document") + } + var matter struct { + Title string `yaml:"title"` + Command struct { + Name string `yaml:"name"` + Args []string `yaml:"arguments"` + ArbitraryArgs []string `yaml:"arbitraryArgs"` + Hidden bool `yaml:"hidden"` + Aliases []string `yaml:"aliases"` + Flags []DocFlag `yaml:"flags"` + } `yaml:"command"` + } + rest, err := frontmatter.Parse(strings.NewReader(doc), &matter) + if err != nil { + return nil, err + } + + c := matter.Command + + if c.Name == "" { + return nil, errors.New("required 'command' property") + } + + long := "# " + matter.Title + "\n\n" + strings.TrimSpace(string(rest)) + + var args cobra.PositionalArgs + if len(c.Args) > 0 { + args = cobra.ExactArgs(len(c.Args)) + } + if len(c.ArbitraryArgs) > 0 { + args = cobra.ArbitraryArgs + } + + d := Doc{ + cobra.Command{ + Use: c.Name, + Args: args, + Hidden: c.Hidden, + Aliases: c.Aliases, + Short: matter.Title, + Long: styleDoc(long), + }, + c.Flags, + nil, + } + + return &d, nil +} diff --git a/otdfctl/pkg/man/style.go b/otdfctl/pkg/man/style.go new file mode 100644 index 0000000000..fcf35eaafe --- /dev/null +++ b/otdfctl/pkg/man/style.go @@ -0,0 +1,50 @@ +//nolint:mnd // styling is magic +package man + +import ( + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glamour/ansi" + "github.com/charmbracelet/glamour/styles" + "golang.org/x/term" +) + +var ( + termWidthDefault = 80 + termWidthWide = 120 +) + +func styleDoc(doc string) string { + w, _, err := term.GetSize(0) + if err != nil { + w = termWidthDefault + } + if w > termWidthWide { + w = termWidthWide + } + // Set up a new glamour instance + // with some options + ds := styles.DarkStyleConfig + // ls := glamour.DefaultStyles["light"] + + ds.Document.Margin = uintPtr(0) + ds.Paragraph.Margin = uintPtr(2) + // Capitalize headers + ds.H1.StylePrimitive = ansi.StylePrimitive{ + Color: stringPtr("#F1F1F1"), + Format: "# {{.text}}", + } + r, _ := glamour.NewTermRenderer( + // glamour.WithAutoStyle(), + glamour.WithStyles(ds), + glamour.WithWordWrap(w), + glamour.WithPreservedNewLines(), + ) + + // Render the content + out, _ := r.Render(doc) + + return out +} + +func stringPtr(s string) *string { return &s } +func uintPtr(u uint) *uint { return &u } diff --git a/otdfctl/pkg/profiles/errors.go b/otdfctl/pkg/profiles/errors.go new file mode 100644 index 0000000000..8bbc90bad8 --- /dev/null +++ b/otdfctl/pkg/profiles/errors.go @@ -0,0 +1,14 @@ +package profiles + +import "errors" + +var ( + ErrDeletingDefaultProfile = errors.New("cannot delete the default profile") + ErrProfileIsEmpty = errors.New("error profile is empty") + ErrProfileIncorrectType = errors.New("error profile is not of type ProfileConfig") + ErrCreatingPlatform = errors.New("error when creating platform") + ErrCreatingNewProfile = errors.New("error creating profile") + ErrUnknownProfileDriverType = errors.New("error unknown profile driver type") + ErrCleaningUpProfiles = errors.New("error occurred when cleaning up profiles") + ErrProfileConfigEmpty = errors.New("error profile configuration cannot be empty") +) diff --git a/otdfctl/pkg/profiles/profile.go b/otdfctl/pkg/profiles/profile.go new file mode 100644 index 0000000000..8fa06cc0b9 --- /dev/null +++ b/otdfctl/pkg/profiles/profile.go @@ -0,0 +1,133 @@ +package profiles + +import ( + "errors" + "log/slog" + "runtime" + "strings" + + osprofiles "github.com/jrschumacher/go-osprofiles" + osplatform "github.com/jrschumacher/go-osprofiles/pkg/platform" + "github.com/opentdf/platform/otdfctl/pkg/config" +) + +type ProfileDriver string + +const ( + ProfileDriverKeyring ProfileDriver = "keyring" + ProfileDriverMemory ProfileDriver = "in-memory" + ProfileDriverFileSystem ProfileDriver = "filesystem" + ProfileDriverUnknown ProfileDriver = "unknown" + ProfileDriverDefault = ProfileDriverFileSystem +) + +func newFileStoreProfiler() (*osprofiles.Profiler, error) { + platform, err := osplatform.NewPlatform(config.ServicePublisher, config.AppName, runtime.GOOS) + if err != nil { + return nil, errors.Join(ErrCreatingPlatform, err) + } + profiler, err := osprofiles.New(config.AppName, osprofiles.WithFileStore(platform.UserAppConfigDirectory())) + if err != nil { + return nil, errors.Join(ErrCreatingNewProfile, err) + } + return profiler, nil +} + +func NewProfiler(store string) (*osprofiles.Profiler, error) { + driverType, err := ToProfileDriver(store) + if err != nil { + return nil, err + } + + return CreateProfiler(driverType) +} + +func ToProfileDriver(driverType string) (ProfileDriver, error) { + normalizedType := strings.ToLower(strings.TrimSpace(driverType)) + switch normalizedType { + case string(ProfileDriverMemory): + return ProfileDriverMemory, nil + case string(ProfileDriverKeyring): + return ProfileDriverKeyring, nil + case string(ProfileDriverFileSystem): + return ProfileDriverFileSystem, nil + case string(ProfileDriverUnknown): + fallthrough + default: + return ProfileDriverUnknown, ErrUnknownProfileDriverType + } +} + +func CreateProfiler(driverType ProfileDriver) (*osprofiles.Profiler, error) { + switch driverType { + case ProfileDriverMemory: + return osprofiles.New(config.AppName, osprofiles.WithInMemoryStore()) + case ProfileDriverKeyring: + return osprofiles.New(config.AppName, osprofiles.WithKeyringStore()) + case ProfileDriverFileSystem: + return newFileStoreProfiler() + case ProfileDriverUnknown: + fallthrough + default: + return nil, ErrUnknownProfileDriverType + } +} + +func Migrate(to ProfileDriver, from ProfileDriver) error { + fromProfiler, err := CreateProfiler(from) + if err != nil { + return err + } + + toProfiler, err := CreateProfiler(to) + if err != nil { + return err + } + + profilesToMigrate := osprofiles.ListProfiles(fromProfiler) + if len(profilesToMigrate) == 0 { + return nil + } + + defaultProfileBeingMigrated := osprofiles.GetGlobalConfig(fromProfiler).GetDefaultProfile() + + slog.Debug("migrating profiles", + slog.Any("count", len(profilesToMigrate)), + slog.Any("from", string(from)), + slog.Any("to", string(to)), + ) + + for _, profileName := range profilesToMigrate { + store, err := osprofiles.GetProfile[*ProfileConfig](fromProfiler, profileName) + if err != nil { + return err + } + + p, ok := store.Profile.(*ProfileConfig) + if !ok || p == nil { + return ErrProfileIncorrectType + } + + setDefault := profileName == defaultProfileBeingMigrated + + if err := toProfiler.AddProfile(p, setDefault); err != nil { + return err + } + + slog.Debug("migrated profile", + slog.String("profile", profileName), + slog.Bool("set_default", setDefault), + ) + } + + slog.Debug("removing profiles", + slog.String("from", string(from)), + slog.Any("count", len(profilesToMigrate)), + ) + if err = fromProfiler.Cleanup(false); err != nil { + return errors.Join(ErrCleaningUpProfiles, err) + } + + slog.Debug("migration complete") + return nil +} diff --git a/otdfctl/pkg/profiles/profileAuthCreds.go b/otdfctl/pkg/profiles/profileAuthCreds.go new file mode 100644 index 0000000000..10db5b9827 --- /dev/null +++ b/otdfctl/pkg/profiles/profileAuthCreds.go @@ -0,0 +1,31 @@ +package profiles + +const ( + AuthTypeClientCredentials = "client-credentials" + AuthTypeAccessToken = "access-token" +) + +type AuthCredentials struct { + AuthType string `json:"authType"` + ClientID string `json:"clientId"` + // Used for client credentials + ClientSecret string `json:"clientSecret,omitempty"` + Scopes []string `json:"scopes,omitempty"` + AccessToken AuthCredentialsAccessToken `json:"accessToken,omitempty"` +} + +type AuthCredentialsAccessToken struct { + ClientID string `json:"clientId"` + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + Expiration int64 `json:"expiration"` +} + +func (p *OtdfctlProfileStore) GetAuthCredentials() AuthCredentials { + return p.config.AuthCredentials +} + +func (p *OtdfctlProfileStore) SetAuthCredentials(authCredentials AuthCredentials) error { + p.config.AuthCredentials = authCredentials + return p.store.Save() +} diff --git a/otdfctl/pkg/profiles/profileConfig.go b/otdfctl/pkg/profiles/profileConfig.go new file mode 100644 index 0000000000..c07e959383 --- /dev/null +++ b/otdfctl/pkg/profiles/profileConfig.go @@ -0,0 +1,167 @@ +package profiles + +import ( + "errors" + "strings" + + osprofiles "github.com/jrschumacher/go-osprofiles" + "github.com/opentdf/platform/otdfctl/pkg/utils" +) + +const ( + OutputJSON = "json" + OutputStyled = "styled" +) + +type OtdfctlProfileStore struct { + store osprofiles.ProfileStore + config *ProfileConfig // Pointer to the store.Profile field + profiler *osprofiles.Profiler +} + +type ProfileConfig struct { + Name string `json:"profile"` + Endpoint string `json:"endpoint"` + TLSNoVerify bool `json:"tlsNoVerify"` + OutputFormat string `json:"outputFormat,omitempty"` + AuthCredentials AuthCredentials `json:"authCredentials"` +} + +func (pc *ProfileConfig) GetName() string { + return pc.Name +} + +func NewOtdfctlProfileStore(storeType ProfileDriver, cfg *ProfileConfig, setDefault bool) (*OtdfctlProfileStore, error) { + if cfg == nil { + return nil, ErrProfileConfigEmpty + } + + profiler, err := CreateProfiler(storeType) + if err != nil { + return nil, err + } + + u, err := utils.NormalizeEndpoint(cfg.Endpoint) + if err != nil { + return nil, err + } + + p := &ProfileConfig{ + Name: cfg.Name, + Endpoint: u.String(), + TLSNoVerify: cfg.TLSNoVerify, + OutputFormat: NormalizeOutputFormat(cfg.OutputFormat), + } + err = profiler.AddProfile(p, setDefault) + if err != nil { + return nil, err + } + + store, err := osprofiles.UseProfile[*ProfileConfig](profiler, cfg.Name) + if err != nil { + return nil, err + } + + // Cast Profile to ProfileConfig + pc, ok := store.Profile.(*ProfileConfig) + if !ok { + return nil, errors.Join(ErrProfileIncorrectType, err) + } + + return newProfileStore(profiler, store, pc), nil +} + +func LoadOtdfctlProfileStore(storeType ProfileDriver, profileName string) (*OtdfctlProfileStore, error) { + profiler, err := CreateProfiler(storeType) + if err != nil { + return nil, err + } + + store, err := osprofiles.GetProfile[*ProfileConfig](profiler, profileName) + if err != nil { + return nil, err + } + + pc, ok := store.Profile.(*ProfileConfig) + if !ok { + return nil, errors.Join(ErrProfileIncorrectType, err) + } + + return newProfileStore(profiler, store, pc), nil +} + +func newProfileStore(profiler *osprofiles.Profiler, store *osprofiles.ProfileStore, pc *ProfileConfig) *OtdfctlProfileStore { + ensureProfileDefaults(pc) + return &OtdfctlProfileStore{ + store: *store, + config: pc, + profiler: profiler, + } +} + +func ensureProfileDefaults(pc *ProfileConfig) { + if pc == nil { + return + } + pc.OutputFormat = NormalizeOutputFormat(pc.OutputFormat) +} + +func (p *OtdfctlProfileStore) GetEndpoint() string { + return p.config.Endpoint +} + +func (p *OtdfctlProfileStore) SetEndpoint(endpoint string) error { + u, err := utils.NormalizeEndpoint(endpoint) + if err != nil { + return err + } + + p.config.Endpoint = u.String() + return p.store.Save() +} + +func (p *OtdfctlProfileStore) GetTLSNoVerify() bool { + return p.config.TLSNoVerify +} + +func (p *OtdfctlProfileStore) SetTLSNoVerify(tlsNoVerify bool) error { + p.config.TLSNoVerify = tlsNoVerify + return p.store.Save() +} + +func (p *OtdfctlProfileStore) GetOutputFormat() string { + return NormalizeOutputFormat(p.config.OutputFormat) +} + +func (p *OtdfctlProfileStore) SetOutputFormat(format string) error { + p.config.OutputFormat = NormalizeOutputFormat(format) + return p.store.Save() +} + +func (p *OtdfctlProfileStore) Name() string { + return p.config.Name +} + +func (p *OtdfctlProfileStore) IsDefault() bool { + return p.Name() == osprofiles.GetGlobalConfig(p.profiler).GetDefaultProfile() +} + +// NormalizeOutputFormat returns a supported output format. Any unknown value defaults to styled output. +func NormalizeOutputFormat(format string) string { + switch strings.ToLower(strings.TrimSpace(format)) { + case OutputJSON: + return OutputJSON + default: + return OutputStyled + } +} + +// IsValidOutputFormat reports whether the provided format string is supported. +func IsValidOutputFormat(format string) bool { + switch strings.ToLower(strings.TrimSpace(format)) { + case OutputJSON, OutputStyled: + return true + default: + return false + } +} diff --git a/otdfctl/pkg/tdf/tdf.go b/otdfctl/pkg/tdf/tdf.go new file mode 100644 index 0000000000..3f0bf65ccf --- /dev/null +++ b/otdfctl/pkg/tdf/tdf.go @@ -0,0 +1,6 @@ +package tdf + +const ( + TypeZTDF = "ztdf" + TypeTDF3 = "tdf3" // alias for TDF +) diff --git a/otdfctl/pkg/utils/http.go b/otdfctl/pkg/utils/http.go new file mode 100644 index 0000000000..e73e2cccef --- /dev/null +++ b/otdfctl/pkg/utils/http.go @@ -0,0 +1,17 @@ +package utils + +import ( + "crypto/tls" + "net/http" +) + +func NewHTTPClient(tlsNoVerify bool) *http.Client { + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + //nolint:gosec // skip tls verification allowed if requested + InsecureSkipVerify: tlsNoVerify, + }, + }, + } +} diff --git a/otdfctl/pkg/utils/identifier.go b/otdfctl/pkg/utils/identifier.go new file mode 100644 index 0000000000..a7e371bb9f --- /dev/null +++ b/otdfctl/pkg/utils/identifier.go @@ -0,0 +1,73 @@ +// pkg/utils/identifier.go +package utils + +import ( + "net/url" + "strings" + + "github.com/google/uuid" +) + +// IdentifierStringType defines the type of string identified. +type IdentifierStringType int + +const ( + // StringTypeUnknown indicates the string type could not be determined or is empty. + StringTypeUnknown IdentifierStringType = iota + // StringTypeUUID indicates the string is a valid UUID. + StringTypeUUID + // StringTypeURI indicates the string is a valid absolute URI. + StringTypeURI + // StringTypeGeneric indicates the string is not a UUID or URI, and can be treated as a generic identifier (e.g., a name). + StringTypeGeneric +) + +// String returns a string representation of the IdentifierStringType. +func (it IdentifierStringType) String() string { + switch it { + case StringTypeUUID: + return "UUID" + case StringTypeURI: + return "URI" + case StringTypeGeneric: + return "Generic" + case StringTypeUnknown: + fallthrough + default: + return "Unknown" + } +} + +// ClassifyString attempts to determine if the input string is a UUID, an absolute URI, or a generic string. +// It prioritizes UUID, then URI, then defaults to Generic. +func ClassifyString(input string) IdentifierStringType { + trimmedInput := strings.TrimSpace(input) + if trimmedInput == "" { + return StringTypeUnknown // Or StringTypeGeneric if empty strings should be treated as such + } + + // Check for UUID + // uuid.Parse is strict and will return an error if the string is not a valid UUID. + if _, err := uuid.Parse(trimmedInput); err == nil { + return StringTypeUUID + } + + // Check for an absolute URI + // url.ParseRequestURI requires the URL to be absolute. + // We also check for a scheme and host to ensure it's a usable network URI. + if parsedURL, err := url.ParseRequestURI(trimmedInput); err == nil { + if parsedURL.Scheme != "" && parsedURL.Host != "" { + return StringTypeURI + } + } + // A slightly more lenient check that also catches schemeless URLs if needed, + // but for KAS identifiers, absolute URIs are usually expected. + // if parsedURL, err := url.Parse(trimmedInput); err == nil { + // if parsedURL.Scheme != "" && parsedURL.Host != "" { + // return StringTypeURI + // } + // } + + // If not a UUID and not a well-formed absolute URI, treat as generic + return StringTypeGeneric +} diff --git a/otdfctl/pkg/utils/identifier_test.go b/otdfctl/pkg/utils/identifier_test.go new file mode 100644 index 0000000000..532f283072 --- /dev/null +++ b/otdfctl/pkg/utils/identifier_test.go @@ -0,0 +1,92 @@ +package utils + +import ( + "testing" +) + +func TestClassifyString(t *testing.T) { + tests := []struct { + name string + input string + expected IdentifierStringType + }{ + { + name: "Valid UUID", + input: "123e4567-e89b-12d3-a456-426614174000", + expected: StringTypeUUID, + }, + { + name: "Valid UUID with spaces", + input: " 123e4567-e89b-12d3-a456-426614174000 ", + expected: StringTypeUUID, + }, + { + name: "Valid URI - https", + input: "https://example.com/path?query=value", + expected: StringTypeURI, + }, + { + name: "Valid URI - http", + input: "http://localhost:8080", + expected: StringTypeURI, + }, + { + name: "Valid URI with spaces", + input: " https://example.com/path ", + expected: StringTypeURI, + }, + { + name: "Generic string - name", + input: "my-kas-server", + expected: StringTypeGeneric, + }, + { + name: "Generic string - simple word", + input: "kas1", + expected: StringTypeGeneric, + }, + { + name: "Generic string with spaces", + input: " My KAS Name ", + expected: StringTypeGeneric, + }, + { + name: "Empty string", + input: "", + expected: StringTypeUnknown, + }, + { + name: "String with only spaces", + input: " ", + expected: StringTypeUnknown, + }, + { + name: "Invalid UUID - too short", + input: "123e4567-e89b-12d3-a456-42661417400", + expected: StringTypeGeneric, // Falls back to generic + }, + { + name: "Invalid URI - no scheme", + input: "example.com/path", + expected: StringTypeGeneric, // Falls back to generic + }, + { + name: "Invalid URI - no host", + input: "https:///path", + expected: StringTypeGeneric, // Falls back to generic + }, + { + name: "String that looks like URI but isn't absolute", + input: "/just/a/path", + expected: StringTypeGeneric, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ClassifyString(tt.input); got != tt.expected { + t.Errorf("ClassifyString() = %v, want %v", got, tt.expected) + } + }) + } +} diff --git a/otdfctl/pkg/utils/pemvalidate.go b/otdfctl/pkg/utils/pemvalidate.go new file mode 100644 index 0000000000..e9de274236 --- /dev/null +++ b/otdfctl/pkg/utils/pemvalidate.go @@ -0,0 +1,51 @@ +package utils + +import ( + "errors" + "fmt" + + "github.com/opentdf/platform/lib/ocrypto" + "github.com/opentdf/platform/protocol/go/policy" +) + +// ValidatePublicKeyPEM validates a PEM-encoded public key block and ensures it +// matches the expected algorithm. The input should be raw PEM bytes (not base64). +func ValidatePublicKeyPEM(pemBytes []byte, expected policy.Algorithm) error { + if len(pemBytes) == 0 { + return errors.New("empty pem input") + } + + enc, err := ocrypto.FromPublicPEM(string(pemBytes)) + if err != nil { + return fmt.Errorf("invalid public key pem: %w", err) + } + + switch expected { + case policy.Algorithm_ALGORITHM_RSA_2048: + if enc.KeyType() != ocrypto.RSA2048Key { + return errors.New("algorithm mismatch: expected RSA 2048") + } + case policy.Algorithm_ALGORITHM_RSA_4096: + if enc.KeyType() != ocrypto.RSA4096Key { + return errors.New("algorithm mismatch: expected RSA 4096") + } + case policy.Algorithm_ALGORITHM_EC_P256: + if enc.KeyType() != ocrypto.EC256Key { + return errors.New("algorithm mismatch: expected EC P-256") + } + case policy.Algorithm_ALGORITHM_EC_P384: + if enc.KeyType() != ocrypto.EC384Key { + return errors.New("algorithm mismatch: expected EC P-384") + } + case policy.Algorithm_ALGORITHM_EC_P521: + if enc.KeyType() != ocrypto.EC521Key { + return errors.New("algorithm mismatch: expected EC P-521") + } + case policy.Algorithm_ALGORITHM_UNSPECIFIED: + fallthrough + default: + return errors.New("unsupported or unspecified algorithm") + } + + return nil +} diff --git a/otdfctl/pkg/utils/pemvalidate_test.go b/otdfctl/pkg/utils/pemvalidate_test.go new file mode 100644 index 0000000000..082b403f5d --- /dev/null +++ b/otdfctl/pkg/utils/pemvalidate_test.go @@ -0,0 +1,120 @@ +package utils + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "testing" + + "github.com/opentdf/platform/protocol/go/policy" + "github.com/stretchr/testify/require" +) + +func pemBlockForKey(t *testing.T, pub interface{}) []byte { + t.Helper() + var der []byte + var err error + switch k := pub.(type) { + case *rsa.PublicKey: + der, err = x509.MarshalPKIXPublicKey(k) + require.NoError(t, err) + case *ecdsa.PublicKey: + der, err = x509.MarshalPKIXPublicKey(k) + require.NoError(t, err) + default: + t.Fatalf("unsupported key type") + } + return pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}) +} + +func TestValidatePublicKeyPEM_RSA2048_OK(t *testing.T) { + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + pub := &k.PublicKey + pemBytes := pemBlockForKey(t, pub) + + err = ValidatePublicKeyPEM(pemBytes, policy.Algorithm_ALGORITHM_RSA_2048) + require.NoError(t, err) +} + +func TestValidatePublicKeyPEM_RSA_SizeMismatch(t *testing.T) { + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + pemBytes := pemBlockForKey(t, &k.PublicKey) + + err = ValidatePublicKeyPEM(pemBytes, policy.Algorithm_ALGORITHM_RSA_4096) + require.Error(t, err) + require.Contains(t, err.Error(), "algorithm mismatch") +} + +func TestValidatePublicKeyPEM_RSA4096_OK(t *testing.T) { + k, err := rsa.GenerateKey(rand.Reader, 4096) + require.NoError(t, err) + pub := &k.PublicKey + pemBytes := pemBlockForKey(t, pub) + + err = ValidatePublicKeyPEM(pemBytes, policy.Algorithm_ALGORITHM_RSA_4096) + require.NoError(t, err) +} + +func TestValidatePublicKeyPEM_EC_P256_OK(t *testing.T) { + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + pemBytes := pemBlockForKey(t, &k.PublicKey) + + err = ValidatePublicKeyPEM(pemBytes, policy.Algorithm_ALGORITHM_EC_P256) + require.NoError(t, err) +} + +func TestValidatePublicKeyPEM_EC_P384_OK(t *testing.T) { + k, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + require.NoError(t, err) + pemBytes := pemBlockForKey(t, &k.PublicKey) + + err = ValidatePublicKeyPEM(pemBytes, policy.Algorithm_ALGORITHM_EC_P384) + require.NoError(t, err) +} + +func TestValidatePublicKeyPEM_EC_P521_OK(t *testing.T) { + k, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + require.NoError(t, err) + pemBytes := pemBlockForKey(t, &k.PublicKey) + + err = ValidatePublicKeyPEM(pemBytes, policy.Algorithm_ALGORITHM_EC_P521) + require.NoError(t, err) +} + +func TestValidatePublicKeyPEM_EC_Mismatch(t *testing.T) { + k, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + require.NoError(t, err) + pemBytes := pemBlockForKey(t, &k.PublicKey) + + err = ValidatePublicKeyPEM(pemBytes, policy.Algorithm_ALGORITHM_EC_P256) + require.Error(t, err) + require.Contains(t, err.Error(), "algorithm mismatch") +} + +func TestValidatePublicKeyPEM_InvalidPEM(t *testing.T) { + err := ValidatePublicKeyPEM([]byte("not a pem"), policy.Algorithm_ALGORITHM_RSA_2048) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid public key pem") +} + +func TestValidatePublicKeyPEM_EmptyPEM(t *testing.T) { + err := ValidatePublicKeyPEM([]byte(""), policy.Algorithm_ALGORITHM_RSA_2048) + require.Error(t, err) + require.Contains(t, err.Error(), "empty pem input") +} + +func TestValidatePublicKeyPEM_UnsupportedAlgorithm(t *testing.T) { + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + pemBytes := pemBlockForKey(t, &k.PublicKey) + + err = ValidatePublicKeyPEM(pemBytes, policy.Algorithm_ALGORITHM_UNSPECIFIED) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported or unspecified algorithm") +} diff --git a/otdfctl/pkg/utils/read.go b/otdfctl/pkg/utils/read.go new file mode 100644 index 0000000000..093a3192b9 --- /dev/null +++ b/otdfctl/pkg/utils/read.go @@ -0,0 +1,34 @@ +package utils + +import ( + "fmt" + "io" + "os" +) + +func ReadBytesFromFile(filePath string, maxBytes int64) ([]byte, error) { + fileInfo, err := os.Stat(filePath) + if err != nil { + return nil, fmt.Errorf("failed to stat file at path %s: %w", filePath, err) + } + + // Check if the file size exceeds the limit + if fileInfo.Size() > maxBytes { + return nil, fmt.Errorf("file size exceeds the limit of %d bytes", maxBytes) + } + + fileToEncrypt, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open file at path %s: %w", filePath, err) + } + defer fileToEncrypt.Close() + + // Limit the reader to the specified maximum number of bytes + limitedReader := io.LimitReader(fileToEncrypt, maxBytes) + bytes, err := io.ReadAll(limitedReader) + if err != nil { + return nil, fmt.Errorf("failed to read bytes from file at path %s: %w", filePath, err) + } + + return bytes, nil +} diff --git a/otdfctl/pkg/utils/validators.go b/otdfctl/pkg/utils/validators.go new file mode 100644 index 0000000000..8569aaf9c6 --- /dev/null +++ b/otdfctl/pkg/utils/validators.go @@ -0,0 +1,33 @@ +package utils + +import ( + "errors" + "net/url" + "strings" +) + +func NormalizeEndpoint(endpoint string) (*url.URL, error) { + if endpoint == "" { + return nil, errors.New("endpoint is required") + } + u, err := url.Parse(endpoint) + if err != nil { + return nil, err + } + switch u.Scheme { + case "http": + if u.Port() == "" { + u.Host += ":80" + } + case "https": + if u.Port() == "" { + u.Host += ":443" + } + default: + return nil, errors.New("invalid scheme") + } + for strings.HasSuffix(u.Path, "/") { + u.Path = strings.TrimSuffix(u.Path, "/") + } + return u, nil +} diff --git a/otdfctl/scripts/verify-checksums.sh b/otdfctl/scripts/verify-checksums.sh new file mode 100755 index 0000000000..6ae3a89199 --- /dev/null +++ b/otdfctl/scripts/verify-checksums.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Check if the required arguments are provided +if [ $# -ne 2 ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Verifying checksums..." +# Location of the checksum file +checksumFile=$1/$2 +outputDir=$1 + +echo "Looking for checksum file: $checksumFile" +test -f "$checksumFile" || { echo "ERROR: Checksum file not found!"; exit 1; } + +# Iterate over each line in the checksum file +while read -r line; do + # Extract the expected checksum and filename from each line + read -ra ADDR <<< "$line" # Read the line into an array + expectedChecksum="${ADDR[0]}" + fileName="${ADDR[2]}" + + # Calculate the actual checksum of the file + actualChecksum=$(shasum -a 256 "$outputDir/$fileName" | awk '{print $1}') + + # Compare the expected checksum with the actual checksum + if [ "$expectedChecksum" == "$actualChecksum" ]; then + echo "SUCCESS: Checksum for $fileName is valid." + else + echo "ERROR: Checksum for $fileName does not match." + fi +done < "$checksumFile" diff --git a/otdfctl/scripts/zip-builds.sh b/otdfctl/scripts/zip-builds.sh new file mode 100755 index 0000000000..36eaa47d91 --- /dev/null +++ b/otdfctl/scripts/zip-builds.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Check if the required arguments are provided +if [ $# -ne 3 ]; then + echo "Usage: $0 " + exit 1 +fi + +# Assign the arguments to variables +build_semver="$1" +binary_dir="$2" +output_dir="$3" + +# Create the output directory if it doesn't exist +mkdir -p "$output_dir" + +# Create a checksums file +checksums_file="$output_dir/${build_semver}_checksums.txt" +touch $checksums_file + +# Iterate over each binary file +for binary_file in "$binary_dir"/*; do + compressed="" + if [[ $binary_file == *.exe ]]; then + # If the file is a Windows binary, zip it + filename=$(basename "$binary_file") + compressed="${filename%.exe}.zip" + zip -j "$output_dir/$compressed" "$binary_file" + else + # For other binaries, tar and gzip them + filename=$(basename "$binary_file") + compressed="${filename}.tar.gz" + tar -czf "$output_dir/$compressed" "$binary_file" + fi + + # Append checksums to the file + echo "$(cat "$output_dir/$compressed" | shasum -a 256) $compressed" >> $checksums_file +done diff --git a/otdfctl/tui/appMenu.go b/otdfctl/tui/appMenu.go new file mode 100644 index 0000000000..5907e9a979 --- /dev/null +++ b/otdfctl/tui/appMenu.go @@ -0,0 +1,105 @@ +//nolint:gocritic // still in development +package tui + +import ( + "context" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/opentdf/platform/otdfctl/pkg/handlers" + "github.com/opentdf/platform/otdfctl/tui/constants" +) + +const ( + mainMenu menuState = iota + namespaceMenu + attributeMenu + entitlementMenu + resourceEncodingMenu + subjectEncodingMenu +) + +type menuState int + +type AppMenuItem struct { + id menuState + title string + description string +} + +func (m AppMenuItem) FilterValue() string { + return m.title +} + +func (m AppMenuItem) Title() string { + return m.title +} + +func (m AppMenuItem) Description() string { + return m.description +} + +type AppMenu struct { + list list.Model + view tea.Model + sdk handlers.Handler +} + +func InitAppMenu(h handlers.Handler) (AppMenu, tea.Cmd) { + m := AppMenu{ + view: nil, + sdk: h, + } + //nolint:mnd // styling is magic + m.list = list.New([]list.Item{}, list.NewDefaultDelegate(), 8, 8) + m.list.Title = "OpenTDF" + m.list.SetItems([]list.Item{ + // AppMenuItem{title: "Namespaces", description: "Manage namespaces", id: namespaceMenu}, + AppMenuItem{title: "Attributes", description: "Manage attributes", id: attributeMenu}, + // AppMenuItem{title: "Entitlements", description: "Manage entitlements", id: entitlementMenu}, + // AppMenuItem{title: "Resource Encodings", description: "Manage resource encodings", id: resourceEncodingMenu}, + // AppMenuItem{title: "Subject Encodings", description: "Manage subject encodings", id: subjectEncodingMenu}, + }) + return m, func() tea.Msg { return nil } +} + +func (m AppMenu) Init() tea.Cmd { + return nil +} + +func (m AppMenu) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + ctx := context.Background() + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + constants.WindowSize = msg + m.list.SetSize(msg.Width, msg.Height) + return m, nil + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "ctrl+d": + return m, nil + case "enter": + switch m.list.SelectedItem().(AppMenuItem).id { + // case namespaceMenu: + // // get namespaces + // nl, cmd := InitNamespaceList([]list.Item{}, 0) + // return nl, cmd + case attributeMenu: + // list attributes + al, cmd := InitAttributeList(ctx, "", m.sdk) + return al, cmd + } + } + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m AppMenu) View() string { + return ViewList(m.list) +} diff --git a/otdfctl/tui/attributeCreateView.go b/otdfctl/tui/attributeCreateView.go new file mode 100644 index 0000000000..5960eb006e --- /dev/null +++ b/otdfctl/tui/attributeCreateView.go @@ -0,0 +1,59 @@ +package tui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/opentdf/platform/otdfctl/tui/constants" +) + +type AttributeCreateModel struct { + form *huh.Form +} + +func InitAttributeCreateModel() (tea.Model, tea.Cmd) { + namespace := "" + m := AttributeCreateModel{} + m.form = huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Namespace"). + Options( + huh.NewOption("demo.com", "demo.com"), + huh.NewOption("demo.net", "demo.net"), + ). + Validate(func(str string) error { + // Check if namespace exists + fmt.Println(str) + return nil + }). + Value(&namespace), + ), + ) + + return m, nil +} + +func (m AttributeCreateModel) Init() tea.Cmd { + return nil +} + +func (m AttributeCreateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + constants.WindowSize = msg + return m, nil + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + } + } + + return m, nil +} + +func (m AttributeCreateModel) View() string { + return "" +} diff --git a/otdfctl/tui/attributeList.go b/otdfctl/tui/attributeList.go new file mode 100644 index 0000000000..cc6676167d --- /dev/null +++ b/otdfctl/tui/attributeList.go @@ -0,0 +1,150 @@ +package tui + +import ( + "context" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/opentdf/platform/otdfctl/pkg/handlers" + "github.com/opentdf/platform/otdfctl/tui/constants" + "github.com/opentdf/platform/protocol/go/common" +) + +type AttributeList struct { + list list.Model + h handlers.Handler +} + +type AttributeItem struct { + id string + name string +} + +func (m AttributeItem) FilterValue() string { + return m.name +} + +func (m AttributeItem) Title() string { + return m.name +} + +func (m AttributeItem) Description() string { + return m.id +} + +func InitAttributeList(ctx context.Context, id string, h handlers.Handler) (tea.Model, tea.Cmd) { + l := list.New([]list.Item{}, list.NewDefaultDelegate(), constants.WindowSize.Width, constants.WindowSize.Height) + // TODO: handle and return error view and use real command flags limit/offset + var ( + limit int32 = 100 + offset int32 = 0 + ) + res, _ := h.ListAttributes(ctx, common.ActiveStateEnum_ACTIVE_STATE_ENUM_ANY, limit, offset) + var attrs []list.Item + selectIdx := 0 + for i, attr := range res.GetAttributes() { + var vals []string + for _, val := range attr.GetValues() { + // TODO: do something with values here + //lint:ignore SA4010 // still in-progress + vals = append(vals, val.GetValue()) + } + if attr.GetId() == id { + selectIdx = i + } + item := AttributeItem{ + id: attr.GetId(), + name: attr.GetName(), + } + attrs = append(attrs, item) + } + l.Title = "Attributes" + l.SetItems(attrs) + l.Select(selectIdx) + m := AttributeList{h: h, list: l} + return m.Update(WindowMsg()) +} + +func (m AttributeList) Init() tea.Cmd { + return nil +} + +func StyleAttr(attr string) string { + return lipgloss.NewStyle(). + Foreground(constants.Magenta). + Render(attr) +} + +func CreateViewFormat(num int) string { + var format string + for i := 0; i < num; i++ { + format += "%s %s\n" + } + return format +} + +func (m AttributeList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + ctx := context.Background() + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + constants.WindowSize = msg + m.list.SetSize(msg.Width, msg.Height) + return m, nil + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "ctrl+[", "backspace": + am, _ := InitAppMenu(m.h) + // make enum for Attributes idx in AppMenu + am.list.Select(0) + return am.Update(WindowMsg()) + // case "c": + // create new attribute + // return InitAttributeView(m.list.Items(), len(m.list.Items())) + case "enter", "e": + return InitAttributeView(ctx, m.list.Items()[m.list.Index()].(AttributeItem).id, m.h) + // case "ctrl+d": + // m.list.RemoveItem(m.list.Index()) + // newIndex := m.list.Index() - 1 + // if newIndex < 0 { + // newIndex = 0 + // } + // m.list.Select(newIndex) + } + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m AttributeList) View() string { + return ViewList(m.list) +} + +// func AddAttribute() { +// var namespace string + +// form := huh.NewForm( +// huh.NewGroup( +// huh.NewSelect[string](). +// Title("Namespace"). +// Options( +// huh.NewOption("demo.com", "demo.com"), +// huh.NewOption("demo.net", "demo.net"), +// ). +// Validate(func(str string) error { +// // Check if namespace exists +// fmt.Println(str) +// return nil +// }). +// Value(&namespace), +// ), +// ) + +// if err := form.Run(); err != nil { +// return +// } +// } diff --git a/otdfctl/tui/attributeView.go b/otdfctl/tui/attributeView.go new file mode 100644 index 0000000000..7196966f5d --- /dev/null +++ b/otdfctl/tui/attributeView.go @@ -0,0 +1,106 @@ +package tui + +import ( + "context" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/handlers" + "github.com/opentdf/platform/otdfctl/tui/constants" + "github.com/opentdf/platform/protocol/go/policy" +) + +type AttributeSubItem struct { + title string + description string +} + +func (m AttributeSubItem) FilterValue() string { + return m.title +} + +func (m AttributeSubItem) Title() string { + return m.title +} + +func (m AttributeSubItem) Description() string { + return m.description +} + +type AttributeView struct { + attr *policy.Attribute + read Read + sdk handlers.Handler +} + +func InitAttributeView(ctx context.Context, id string, h handlers.Handler) (AttributeView, tea.Cmd) { + // TODO: handle and return error view + attr, _ := h.GetAttribute(ctx, id) + sa := cli.GetSimpleAttribute(attr) + items := []list.Item{ + AttributeSubItem{title: "ID", description: sa.ID}, + AttributeSubItem{title: "Name", description: sa.Name}, + AttributeSubItem{title: "Rule", description: sa.Rule}, + AttributeSubItem{title: "Values", description: cli.CommaSeparated(sa.Values)}, + AttributeSubItem{title: "Namespace", description: sa.Namespace}, + AttributeSubItem{title: "Active", description: sa.Active}, + AttributeSubItem{title: "Labels", description: sa.Metadata["Labels"]}, + AttributeSubItem{title: "Created At", description: sa.Metadata["Created At"]}, + AttributeSubItem{title: "Updated At", description: sa.Metadata["Updated At"]}, + } + model, _ := InitRead("Read Attribute", items) + + mod, _ := model.(Read) + m := AttributeView{sdk: h, attr: attr, read: mod} + model, msg := m.Update(WindowMsg()) + m = model.(AttributeView) + return m, msg +} + +func (m AttributeView) Init() tea.Cmd { + return nil +} + +func (m AttributeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + ctx := context.Background() + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + constants.WindowSize = msg + m.read.list.SetSize(msg.Width, msg.Height) + return m, nil + case tea.KeyMsg: + switch msg.String() { + case "backspace": + return InitAttributeList(ctx, m.attr.GetId(), m.sdk) + case "ctrl+c", "q": + return m, tea.Quit + case "ctrl+d": + return m, nil + case "enter": + if m.read.list.SelectedItem().(AttributeSubItem).title == "Labels" { + return InitLabelList(m.attr, m.sdk) + } + // case "enter": + // switch m.list.SelectedItem().(AttributeItem).id { + // // case namespaceMenu: + // // // get namespaces + // // nl, cmd := InitNamespaceList([]list.Item{}, 0) + // // return nl, cmd + // case attributeMenu: + // // list attributes + // al, cmd := InitAttributeList("", m.sdk) + // return al, cmd + // } + } + } + + var cmd tea.Cmd + m.read.list, cmd = m.read.list.Update(msg) + return m, cmd +} + +func (m AttributeView) View() string { + return m.read.View() +} diff --git a/otdfctl/tui/common.go b/otdfctl/tui/common.go new file mode 100644 index 0000000000..2a334e35f0 --- /dev/null +++ b/otdfctl/tui/common.go @@ -0,0 +1,45 @@ +package tui + +import ( + "log" + "os" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/opentdf/platform/otdfctl/pkg/cli" + "github.com/opentdf/platform/otdfctl/pkg/handlers" + "github.com/opentdf/platform/otdfctl/tui/constants" +) + +// StartTea the entry point for the UI. Initializes the model. +func StartTea(h handlers.Handler) error { + if f, err := tea.LogToFile("debug.log", "help"); err != nil { + cli.ExitWithError("Couldn't open a file for logging:", err) + os.Exit(1) + } else { + defer func() { + err = f.Close() + if err != nil { + log.Fatal(err) + } + }() + } + + m, _ := InitAppMenu(h) + constants.P = tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + if _, err := constants.P.Run(); err != nil { + cli.ExitWithError("Error running program:", err) + } + return nil +} + +func ViewList(m list.Model) string { + //nolint:mnd // styling is magic + lipgloss.NewStyle().Padding(1, 2, 1, 2) + return lipgloss.JoinVertical(lipgloss.Top, m.View()) +} + +func WindowMsg() tea.WindowSizeMsg { + return tea.WindowSizeMsg{Width: constants.WindowSize.Width, Height: constants.WindowSize.Height} +} diff --git a/otdfctl/tui/constants/consts.go b/otdfctl/tui/constants/consts.go new file mode 100644 index 0000000000..8184f6ec84 --- /dev/null +++ b/otdfctl/tui/constants/consts.go @@ -0,0 +1,17 @@ +package constants + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + P *tea.Program + + WindowSize struct { + Width int + Height int + } + + Magenta = lipgloss.Color("#EE6FF8") +) diff --git a/otdfctl/tui/form/addAttribute.go b/otdfctl/tui/form/addAttribute.go new file mode 100644 index 0000000000..931b2f1aa0 --- /dev/null +++ b/otdfctl/tui/form/addAttribute.go @@ -0,0 +1,100 @@ +package forms + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/opentdf/platform/protocol/go/policy" +) + +type AttributeDefinition struct { + Name string + Namespace string + Description string + Labels map[string]string + Type string + Rule policy.AttributeRuleTypeEnum + Values []string +} + +func AddAttribute() (AttributeDefinition, error) { + attr := AttributeDefinition{} + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Namespace"). + Description("Select a namespace. To create a namespace go back and select 'Add Namespace'"). + Options( + huh.NewOption("demo.com", "demo.com"), + ). + Value(&attr.Namespace), + + huh.NewInput(). + Title("Attribute Name"). + Value(&attr.Name), + + // Description + huh.NewText(). + Title("Description"). + Value(&attr.Description), + + // Select Rule + huh.NewSelect[policy.AttributeRuleTypeEnum](). + Title("Rule"). + Options( + huh.NewOption("All Of", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF), + huh.NewOption("Any Of", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF), + huh.NewOption("Hierarchical", policy.AttributeRuleTypeEnum_ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY), + ). + Value(&attr.Rule), + ), + ) + + if err := form.Run(); err != nil { + return attr, err + } + + for { + value, another, err := addValue() + if err != nil { + return attr, err + } + + if value == "" { + fmt.Print("Value cannot be empty\n") + continue + } + + attr.Values = append(attr.Values, value) + + if !another { + break + } + } + + return attr, nil +} + +func addValue() (string, bool, error) { + var ( + value string + another bool + err error + ) + valueForm := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Value"). + Value(&value), + + huh.NewConfirm(). + Title("Add Another Value"). + Value(&another), + ), + ) + + err = valueForm.Run() + + return value, another, err +} diff --git a/otdfctl/tui/labelList.go b/otdfctl/tui/labelList.go new file mode 100644 index 0000000000..995b906dc2 --- /dev/null +++ b/otdfctl/tui/labelList.go @@ -0,0 +1,86 @@ +package tui + +import ( + "context" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/opentdf/platform/otdfctl/pkg/handlers" + "github.com/opentdf/platform/otdfctl/tui/constants" + "github.com/opentdf/platform/protocol/go/policy" +) + +type LabelList struct { + attr *policy.Attribute + sdk handlers.Handler + read Read +} + +type LabelItem struct { + title string + description string +} + +func (m LabelItem) FilterValue() string { + return m.title +} + +func (m LabelItem) Title() string { + return m.title +} + +func (m LabelItem) Description() string { + return m.description +} + +func InitLabelList(attr *policy.Attribute, sdk handlers.Handler) (tea.Model, tea.Cmd) { + labels := attr.GetMetadata().GetLabels() + var items []list.Item + for k, v := range labels { + item := LabelItem{ + title: k, + description: v, + } + items = append(items, item) + } + model, _ := InitRead("Read Labels", items) + // TODO: handle and return error view + mod, _ := model.(Read) + return LabelList{attr: attr, sdk: sdk, read: mod}, nil +} + +func (m LabelList) Init() tea.Cmd { + return nil +} + +func (m LabelList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + ctx := context.Background() + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + constants.WindowSize = msg + m.read.list.SetSize(msg.Width, msg.Height) + return m, nil + case tea.KeyMsg: + switch msg.String() { + case "backspace": + return InitAttributeView(ctx, m.attr.GetId(), m.sdk) + case "ctrl+c", "q", "esc": + return m, tea.Quit + case "enter", "e": + return InitLabelUpdate(m.read.list.Items()[m.read.list.Index()].(LabelItem), m.attr, m.sdk), nil + case "c": + return InitLabelUpdate(LabelItem{}, m.attr, m.sdk), nil + case "d": + // delete label + return m, nil + } + } + var cmd tea.Cmd + m.read.list, cmd = m.read.list.Update(msg) + return m, cmd +} + +func (m LabelList) View() string { + return ViewList(m.read.list) +} diff --git a/otdfctl/tui/labelUpdate.go b/otdfctl/tui/labelUpdate.go new file mode 100644 index 0000000000..5059719704 --- /dev/null +++ b/otdfctl/tui/labelUpdate.go @@ -0,0 +1,62 @@ +package tui + +import ( + "context" + + tea "github.com/charmbracelet/bubbletea" + "github.com/opentdf/platform/otdfctl/pkg/handlers" + "github.com/opentdf/platform/protocol/go/common" + "github.com/opentdf/platform/protocol/go/policy" +) + +type LabelUpdate struct { + label LabelItem + update Update + attr *policy.Attribute + sdk handlers.Handler +} + +func InitLabelUpdate(label LabelItem, attr *policy.Attribute, sdk handlers.Handler) LabelUpdate { + return LabelUpdate{ + label: label, + update: InitUpdate([]string{"Key", "Value"}, []string{label.title, label.description}), + attr: attr, + sdk: sdk, + } +} + +func (m LabelUpdate) Init() tea.Cmd { + return nil +} + +func (m LabelUpdate) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + ctx := context.Background() + + if msg, ok := msg.(tea.KeyMsg); ok { + switch msg.String() { + case "enter": + if m.update.focusIndex == len(m.update.inputs) { + // update the label + metadata := &common.MetadataMutable{Labels: m.attr.GetMetadata().GetLabels()} + oldKey := m.label.title + newKey := m.update.inputs[0].Value() + newVal := m.update.inputs[1].Value() + if oldKey != newKey { + delete(metadata.GetLabels(), oldKey) + } + metadata.Labels[newKey] = newVal + behavior := common.MetadataUpdateEnum_METADATA_UPDATE_ENUM_REPLACE + // TODO: handle and return error view + attr, _ := m.sdk.UpdateAttribute(ctx, m.attr.GetId(), metadata, behavior) + return InitLabelList(attr, m.sdk) + } + } + } + update, cmd := m.update.Update(msg) + m.update = update.(Update) + return m, cmd +} + +func (m LabelUpdate) View() string { + return m.update.View() +} diff --git a/otdfctl/tui/read.go b/otdfctl/tui/read.go new file mode 100644 index 0000000000..e53f0676c9 --- /dev/null +++ b/otdfctl/tui/read.go @@ -0,0 +1,44 @@ +package tui + +import ( + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/opentdf/platform/otdfctl/tui/constants" +) + +type Read struct { + list list.Model + width int +} + +func InitRead(title string, items []list.Item) (tea.Model, tea.Cmd) { + m := Read{} + m.list = list.New(items, list.NewDefaultDelegate(), constants.WindowSize.Width, constants.WindowSize.Height) + m.list.Title = title + return m.Update(WindowMsg()) +} + +func (m Read) Init() tea.Cmd { + return nil +} + +func (m Read) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + constants.WindowSize = msg + m.list.SetSize(msg.Width, msg.Height) + m.width = msg.Width + return m, nil + case tea.KeyMsg: + //nolint:exhaustive // only interested in a few key types + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + } + } + return m, nil +} + +func (m Read) View() string { + return ViewList(m.list) +} diff --git a/otdfctl/tui/update.go b/otdfctl/tui/update.go new file mode 100644 index 0000000000..bc45898789 --- /dev/null +++ b/otdfctl/tui/update.go @@ -0,0 +1,163 @@ +package tui + +// A simple example demonstrating the use of multiple text input components +// from the Bubbles component library. + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/cursor" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + cursorStyle = focusedStyle + noStyle = lipgloss.NewStyle() + helpStyle = blurredStyle + cursorModeHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + + focusedButton = focusedStyle.Render("[ Submit ]") + blurredButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("Submit")) +) + +type Update struct { + focusIndex int + inputs []textinput.Model + cursorMode cursor.Mode + keys []string +} + +func InitUpdate(keys []string, vals []string) Update { + m := Update{ + inputs: make([]textinput.Model, len(keys)), + keys: keys, + } + + var t textinput.Model + for i := range m.inputs { + t = textinput.New() + t.Cursor.Style = cursorStyle + t.CharLimit = 32 + t.SetValue(vals[i]) + if i == 0 { + t.Focus() + t.PromptStyle = focusedStyle + t.TextStyle = focusedStyle + } + + m.inputs[i] = t + } + + return m +} + +func (m Update) Init() tea.Cmd { + return textinput.Blink +} + +func (m Update) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if msg, ok := msg.(tea.KeyMsg); ok { + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + + // Change cursor mode + case "ctrl+r": + m.cursorMode++ + if m.cursorMode > cursor.CursorHide { + m.cursorMode = cursor.CursorBlink + } + cmds := make([]tea.Cmd, len(m.inputs)) + for i := range m.inputs { + cmds[i] = m.inputs[i].Cursor.SetMode(m.cursorMode) + } + return m, tea.Batch(cmds...) + + // Set focus to next input + case "tab", "shift+tab", "enter", "up", "down": + s := msg.String() + + // Did the user press enter while the submit button was focused? + // If so, exit. + if s == "enter" && m.focusIndex == len(m.inputs) { + return m, nil + } + + // Cycle indexes + if s == "up" || s == "shift+tab" { + m.focusIndex-- + } else { + m.focusIndex++ + } + + if m.focusIndex > len(m.inputs) { + m.focusIndex = 0 + } else if m.focusIndex < 0 { + m.focusIndex = len(m.inputs) + } + + cmds := make([]tea.Cmd, len(m.inputs)) + for i := 0; i <= len(m.inputs)-1; i++ { + if i == m.focusIndex { + // Set focused state + cmds[i] = m.inputs[i].Focus() + m.inputs[i].PromptStyle = focusedStyle + m.inputs[i].TextStyle = focusedStyle + continue + } + // Remove focused state + m.inputs[i].Blur() + m.inputs[i].PromptStyle = noStyle + m.inputs[i].TextStyle = noStyle + } + + return m, tea.Batch(cmds...) + } + } + + // Handle character input and blinking + cmd := m.updateInputs(msg) + + return m, cmd +} + +func (m *Update) updateInputs(msg tea.Msg) tea.Cmd { + cmds := make([]tea.Cmd, len(m.inputs)) + + // Only text inputs with Focus() set will respond, so it's safe to simply + // update all of them here without any further logic. + for i := range m.inputs { + m.inputs[i], cmds[i] = m.inputs[i].Update(msg) + } + + return tea.Batch(cmds...) +} + +func (m Update) View() string { + var b strings.Builder + + for i := range m.inputs { + b.WriteString(m.keys[i] + "\n") + b.WriteString(m.inputs[i].View()) + if i < len(m.inputs)-1 { + b.WriteRune('\n') + } + } + + button := &blurredButton + if m.focusIndex == len(m.inputs) { + button = &focusedButton + } + fmt.Fprintf(&b, "\n\n%s\n\n", *button) + + b.WriteString(helpStyle.Render("cursor mode is ")) + b.WriteString(cursorModeHelpStyle.Render(m.cursorMode.String())) + b.WriteString(helpStyle.Render(" (ctrl+r to change style)")) + + return b.String() +} diff --git a/service/integration/obligations_test.go b/service/integration/obligations_test.go index d2afecaee6..2f51bc3672 100644 --- a/service/integration/obligations_test.go +++ b/service/integration/obligations_test.go @@ -1721,6 +1721,198 @@ func (s *ObligationsSuite) Test_GetObligation_ByIdAndFqn_ReturnSameResult() { s.True(proto.Equal(oblByID, oblByFQN)) } +// Sort by Name + +func (s *ObligationsSuite) Test_ListObligations_SortByName_ASC() { + ids := s.createNamedSortTestObligations([]string{"aaa-sort", "bbb-sort", "ccc-sort"}) + defer s.deleteObligations(ids) + + listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ + Sort: []*obligations.ObligationsSort{ + {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_NAME, Direction: policy.SortDirection_SORT_DIRECTION_ASC}, + }, + }) + s.Require().NoError(err) + s.NotNil(listRsp) + + // aaa < bbb < ccc in ASC order + assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, ids[0], ids[1], ids[2]) +} + +func (s *ObligationsSuite) Test_ListObligations_SortByName_DESC() { + ids := s.createNamedSortTestObligations([]string{"aaa-sortdesc", "bbb-sortdesc", "ccc-sortdesc"}) + defer s.deleteObligations(ids) + + listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ + Sort: []*obligations.ObligationsSort{ + {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_NAME, Direction: policy.SortDirection_SORT_DIRECTION_DESC}, + }, + }) + s.Require().NoError(err) + s.NotNil(listRsp) + + // ccc > bbb > aaa in DESC order + assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, ids[2], ids[1], ids[0]) +} + +// Sort by FQN + +func (s *ObligationsSuite) Test_ListObligations_SortByFqn_ASC() { + // Create obligations across two namespaces to prove FQN sort uses the full + // constructed FQN (namespace_fqn/obl/name), not just the name. + // "example.com" < "example.net" lexicographically, so even zzz in example.com + // sorts before aaa in example.net. Within example.com, name breaks the tie. + comID, _, _ := s.getNamespaceData(nsExampleCom) + netID, _, _ := s.getNamespaceData(nsExampleNet) + suffix := fmt.Sprintf("fqnasc-%d", time.Now().UnixNano()) + + oblComAAA := s.createObligation(comID, "aaa-"+suffix, nil) + oblComZZZ := s.createObligation(comID, "zzz-"+suffix, nil) + oblNetAAA := s.createObligation(netID, "aaa-"+suffix, nil) + defer s.deleteObligations([]string{oblComAAA.GetId(), oblComZZZ.GetId(), oblNetAAA.GetId()}) + + listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ + Sort: []*obligations.ObligationsSort{ + {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_FQN, Direction: policy.SortDirection_SORT_DIRECTION_ASC}, + }, + }) + s.Require().NoError(err) + s.NotNil(listRsp) + + // example.com/obl/aaa < example.com/obl/zzz < example.net/obl/aaa + assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, oblComAAA.GetId(), oblComZZZ.GetId(), oblNetAAA.GetId()) +} + +func (s *ObligationsSuite) Test_ListObligations_SortByFqn_DESC() { + comID, _, _ := s.getNamespaceData(nsExampleCom) + netID, _, _ := s.getNamespaceData(nsExampleNet) + suffix := fmt.Sprintf("fqndesc-%d", time.Now().UnixNano()) + + oblComAAA := s.createObligation(comID, "aaa-"+suffix, nil) + oblComZZZ := s.createObligation(comID, "zzz-"+suffix, nil) + oblNetAAA := s.createObligation(netID, "aaa-"+suffix, nil) + defer s.deleteObligations([]string{oblComAAA.GetId(), oblComZZZ.GetId(), oblNetAAA.GetId()}) + + listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ + Sort: []*obligations.ObligationsSort{ + {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_FQN, Direction: policy.SortDirection_SORT_DIRECTION_DESC}, + }, + }) + s.Require().NoError(err) + s.NotNil(listRsp) + + // example.net/obl/aaa > example.com/obl/zzz > example.com/obl/aaa + assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, oblNetAAA.GetId(), oblComZZZ.GetId(), oblComAAA.GetId()) +} + +// Sort by CreatedAt + +func (s *ObligationsSuite) Test_ListObligations_SortByCreatedAt_ASC() { + ids := s.createSortTestObligations("createdasc-obl") + defer s.deleteObligations(ids) + + listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ + Sort: []*obligations.ObligationsSort{ + {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_CREATED_AT, Direction: policy.SortDirection_SORT_DIRECTION_ASC}, + }, + }) + s.Require().NoError(err) + s.NotNil(listRsp) + + // oldest first in ASC order + assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, ids[0], ids[1], ids[2]) +} + +func (s *ObligationsSuite) Test_ListObligations_SortByCreatedAt_DESC() { + ids := s.createSortTestObligations("createddesc-obl") + defer s.deleteObligations(ids) + + listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ + Sort: []*obligations.ObligationsSort{ + {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_CREATED_AT, Direction: policy.SortDirection_SORT_DIRECTION_DESC}, + }, + }) + s.Require().NoError(err) + s.NotNil(listRsp) + + // newest first in DESC order + assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, ids[2], ids[1], ids[0]) +} + +// Sort by UpdatedAt + +func (s *ObligationsSuite) Test_ListObligations_SortByUpdatedAt_DESC() { + ids := s.createSortTestObligations("upd-sort-obl") + defer s.deleteObligations(ids) + + // Update the first obligation so its updated_at is the most recent + time.Sleep(5 * time.Millisecond) + _, err := s.db.PolicyClient.UpdateObligation(s.ctx, &obligations.UpdateObligationRequest{ + Id: ids[0], + Metadata: &common.MetadataMutable{ + Labels: map[string]string{"updated": "true"}, + }, + MetadataUpdateBehavior: common.MetadataUpdateEnum_METADATA_UPDATE_ENUM_REPLACE, + }) + s.Require().NoError(err) + + listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ + Sort: []*obligations.ObligationsSort{ + {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_UPDATED_AT, Direction: policy.SortDirection_SORT_DIRECTION_DESC}, + }, + }) + s.Require().NoError(err) + s.NotNil(listRsp) + + // The updated obligation (ids[0]) should appear before the others + assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, ids[0], ids[2], ids[1]) +} + +func (s *ObligationsSuite) Test_ListObligations_SortByUpdatedAt_ASC() { + ids := s.createSortTestObligations("upd-sort-asc-obl") + defer s.deleteObligations(ids) + + // Update the last obligation so its updated_at is the most recent + time.Sleep(5 * time.Millisecond) + _, err := s.db.PolicyClient.UpdateObligation(s.ctx, &obligations.UpdateObligationRequest{ + Id: ids[2], + Metadata: &common.MetadataMutable{ + Labels: map[string]string{"updated": "true"}, + }, + MetadataUpdateBehavior: common.MetadataUpdateEnum_METADATA_UPDATE_ENUM_REPLACE, + }) + s.Require().NoError(err) + + listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ + Sort: []*obligations.ObligationsSort{ + {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_UPDATED_AT, Direction: policy.SortDirection_SORT_DIRECTION_ASC}, + }, + }) + s.Require().NoError(err) + s.NotNil(listRsp) + + // The updated obligation (ids[2]) should appear last in ASC order + assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, ids[0], ids[1], ids[2]) +} + +// Sort by Unspecified (fallback to default) + +func (s *ObligationsSuite) Test_ListObligations_SortByUnspecifiedField_FallsBackToDefault() { + ids := s.createSortTestObligations("unspecified-sort-obl") + defer s.deleteObligations(ids) + + listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ + Sort: []*obligations.ObligationsSort{ + {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_UNSPECIFIED, Direction: policy.SortDirection_SORT_DIRECTION_ASC}, + }, + }) + s.Require().NoError(err) + s.NotNil(listRsp) + + // Falls back to default created_at DESC ordering + assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, ids[2], ids[1], ids[0]) +} + // Helper functions for common operations func (s *ObligationsSuite) getNamespaceData(nsName string) (string, string, fixtures.FixtureDataNamespace) { @@ -1966,198 +2158,6 @@ func (s *ObligationsSuite) assertObligationValuesSpecificTriggers(obl *policy.Ob } } -// Sort by Name - -func (s *ObligationsSuite) Test_ListObligations_SortByName_ASC() { - ids := s.createNamedSortTestObligations([]string{"aaa-sort", "bbb-sort", "ccc-sort"}) - defer s.deleteObligations(ids) - - listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ - Sort: []*obligations.ObligationsSort{ - {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_NAME, Direction: policy.SortDirection_SORT_DIRECTION_ASC}, - }, - }) - s.Require().NoError(err) - s.NotNil(listRsp) - - // aaa < bbb < ccc in ASC order - assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, ids[0], ids[1], ids[2]) -} - -func (s *ObligationsSuite) Test_ListObligations_SortByName_DESC() { - ids := s.createNamedSortTestObligations([]string{"aaa-sortdesc", "bbb-sortdesc", "ccc-sortdesc"}) - defer s.deleteObligations(ids) - - listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ - Sort: []*obligations.ObligationsSort{ - {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_NAME, Direction: policy.SortDirection_SORT_DIRECTION_DESC}, - }, - }) - s.Require().NoError(err) - s.NotNil(listRsp) - - // ccc > bbb > aaa in DESC order - assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, ids[2], ids[1], ids[0]) -} - -// Sort by FQN - -func (s *ObligationsSuite) Test_ListObligations_SortByFqn_ASC() { - // Create obligations across two namespaces to prove FQN sort uses the full - // constructed FQN (namespace_fqn/obl/name), not just the name. - // "example.com" < "example.net" lexicographically, so even zzz in example.com - // sorts before aaa in example.net. Within example.com, name breaks the tie. - comID, _, _ := s.getNamespaceData(nsExampleCom) - netID, _, _ := s.getNamespaceData(nsExampleNet) - suffix := fmt.Sprintf("fqnasc-%d", time.Now().UnixNano()) - - oblComAAA := s.createObligation(comID, "aaa-"+suffix, nil) - oblComZZZ := s.createObligation(comID, "zzz-"+suffix, nil) - oblNetAAA := s.createObligation(netID, "aaa-"+suffix, nil) - defer s.deleteObligations([]string{oblComAAA.GetId(), oblComZZZ.GetId(), oblNetAAA.GetId()}) - - listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ - Sort: []*obligations.ObligationsSort{ - {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_FQN, Direction: policy.SortDirection_SORT_DIRECTION_ASC}, - }, - }) - s.Require().NoError(err) - s.NotNil(listRsp) - - // example.com/obl/aaa < example.com/obl/zzz < example.net/obl/aaa - assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, oblComAAA.GetId(), oblComZZZ.GetId(), oblNetAAA.GetId()) -} - -func (s *ObligationsSuite) Test_ListObligations_SortByFqn_DESC() { - comID, _, _ := s.getNamespaceData(nsExampleCom) - netID, _, _ := s.getNamespaceData(nsExampleNet) - suffix := fmt.Sprintf("fqndesc-%d", time.Now().UnixNano()) - - oblComAAA := s.createObligation(comID, "aaa-"+suffix, nil) - oblComZZZ := s.createObligation(comID, "zzz-"+suffix, nil) - oblNetAAA := s.createObligation(netID, "aaa-"+suffix, nil) - defer s.deleteObligations([]string{oblComAAA.GetId(), oblComZZZ.GetId(), oblNetAAA.GetId()}) - - listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ - Sort: []*obligations.ObligationsSort{ - {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_FQN, Direction: policy.SortDirection_SORT_DIRECTION_DESC}, - }, - }) - s.Require().NoError(err) - s.NotNil(listRsp) - - // example.net/obl/aaa > example.com/obl/zzz > example.com/obl/aaa - assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, oblNetAAA.GetId(), oblComZZZ.GetId(), oblComAAA.GetId()) -} - -// Sort by CreatedAt - -func (s *ObligationsSuite) Test_ListObligations_SortByCreatedAt_ASC() { - ids := s.createSortTestObligations("createdasc-obl") - defer s.deleteObligations(ids) - - listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ - Sort: []*obligations.ObligationsSort{ - {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_CREATED_AT, Direction: policy.SortDirection_SORT_DIRECTION_ASC}, - }, - }) - s.Require().NoError(err) - s.NotNil(listRsp) - - // oldest first in ASC order - assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, ids[0], ids[1], ids[2]) -} - -func (s *ObligationsSuite) Test_ListObligations_SortByCreatedAt_DESC() { - ids := s.createSortTestObligations("createddesc-obl") - defer s.deleteObligations(ids) - - listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ - Sort: []*obligations.ObligationsSort{ - {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_CREATED_AT, Direction: policy.SortDirection_SORT_DIRECTION_DESC}, - }, - }) - s.Require().NoError(err) - s.NotNil(listRsp) - - // newest first in DESC order - assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, ids[2], ids[1], ids[0]) -} - -// Sort by UpdatedAt - -func (s *ObligationsSuite) Test_ListObligations_SortByUpdatedAt_DESC() { - ids := s.createSortTestObligations("upd-sort-obl") - defer s.deleteObligations(ids) - - // Update the first obligation so its updated_at is the most recent - time.Sleep(5 * time.Millisecond) - _, err := s.db.PolicyClient.UpdateObligation(s.ctx, &obligations.UpdateObligationRequest{ - Id: ids[0], - Metadata: &common.MetadataMutable{ - Labels: map[string]string{"updated": "true"}, - }, - MetadataUpdateBehavior: common.MetadataUpdateEnum_METADATA_UPDATE_ENUM_REPLACE, - }) - s.Require().NoError(err) - - listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ - Sort: []*obligations.ObligationsSort{ - {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_UPDATED_AT, Direction: policy.SortDirection_SORT_DIRECTION_DESC}, - }, - }) - s.Require().NoError(err) - s.NotNil(listRsp) - - // The updated obligation (ids[0]) should appear before the others - assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, ids[0], ids[2], ids[1]) -} - -func (s *ObligationsSuite) Test_ListObligations_SortByUpdatedAt_ASC() { - ids := s.createSortTestObligations("upd-sort-asc-obl") - defer s.deleteObligations(ids) - - // Update the last obligation so its updated_at is the most recent - time.Sleep(5 * time.Millisecond) - _, err := s.db.PolicyClient.UpdateObligation(s.ctx, &obligations.UpdateObligationRequest{ - Id: ids[2], - Metadata: &common.MetadataMutable{ - Labels: map[string]string{"updated": "true"}, - }, - MetadataUpdateBehavior: common.MetadataUpdateEnum_METADATA_UPDATE_ENUM_REPLACE, - }) - s.Require().NoError(err) - - listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ - Sort: []*obligations.ObligationsSort{ - {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_UPDATED_AT, Direction: policy.SortDirection_SORT_DIRECTION_ASC}, - }, - }) - s.Require().NoError(err) - s.NotNil(listRsp) - - // The updated obligation (ids[2]) should appear last in ASC order - assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, ids[0], ids[1], ids[2]) -} - -// Sort by Unspecified (fallback to default) - -func (s *ObligationsSuite) Test_ListObligations_SortByUnspecifiedField_FallsBackToDefault() { - ids := s.createSortTestObligations("unspecified-sort-obl") - defer s.deleteObligations(ids) - - listRsp, _, err := s.db.PolicyClient.ListObligations(s.ctx, &obligations.ListObligationsRequest{ - Sort: []*obligations.ObligationsSort{ - {Field: obligations.SortObligationsType_SORT_OBLIGATIONS_TYPE_UNSPECIFIED, Direction: policy.SortDirection_SORT_DIRECTION_ASC}, - }, - }) - s.Require().NoError(err) - s.NotNil(listRsp) - - // Falls back to default created_at DESC ordering - assertIDsInOrder(s.T(), listRsp, func(o *policy.Obligation) string { return o.GetId() }, ids[2], ids[1], ids[0]) -} - // Sort test helpers // createSortTestObligations creates 3 obligations with 5ms gaps for distinct timestamps. diff --git a/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go b/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go index 36c305c6d9..dcc18fa743 100644 --- a/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go +++ b/service/internal/subjectmappingbuiltin/subject_mapping_builtin_actions.go @@ -68,38 +68,13 @@ func EvaluateSubjectMappingsWithActions( // each subject mapping that is true should permit the actions on the mapped value if subjectMappingResult { - // add value FQN to the entitlements set if _, ok := entitlementsSet[valueFQN]; !ok { entitlementsSet[valueFQN] = make([]*policy.Action, 0) } - actions := subjectMapping.GetActions() - - // Cache each action by name to deduplicate. - // In normal operation, same-name conflicting actions should be prevented - // earlier by policy service create/update validation, which enforces - // namespace consistency between subject mappings and referenced actions. - // This extra conflict check is defensive for unexpected or mixed legacy - // states in-memory; if encountered, keep deterministic behavior and log. - m := make(map[string]*policy.Action, len(actions)) - for _, action := range actions { - key := strings.ToLower(action.GetName()) - if existing, ok := m[key]; ok { - if actionsConflict(existing, action) { - if l != nil { - l.Warn( - "subject mapping action name collision with conflicting identity; using deterministic preference", - slog.String("action_name", key), - slog.Any("existing_action", existing), - slog.Any("candidate_action", action), - ) - } - } - m[key] = preferAction(existing, action) - continue - } - m[key] = action - } - entitlementsSet[valueFQN] = append(entitlementsSet[valueFQN], slices.Collect(maps.Values(m))...) + entitlementsSet[valueFQN] = append( + entitlementsSet[valueFQN], + dedupeSubjectMappingActions(subjectMapping.GetActions(), l)..., + ) } } } @@ -108,6 +83,34 @@ func EvaluateSubjectMappingsWithActions( return entitlementsSet, nil } +// dedupeSubjectMappingActions caches actions by lowercased name and returns +// the deduplicated set. In normal operation, same-name conflicting actions +// should be prevented earlier by policy service create/update validation, +// which enforces namespace consistency between subject mappings and referenced +// actions. This extra conflict check is defensive for unexpected or mixed +// legacy states in-memory; if encountered, keep deterministic behavior and log. +func dedupeSubjectMappingActions(actions []*policy.Action, l *slog.Logger) []*policy.Action { + m := make(map[string]*policy.Action, len(actions)) + for _, action := range actions { + key := strings.ToLower(action.GetName()) + existing, ok := m[key] + if !ok { + m[key] = action + continue + } + if actionsConflict(existing, action) && l != nil { + l.Warn( + "subject mapping action name collision with conflicting identity; using deterministic preference", + slog.String("action_name", key), + slog.Any("existing_action", existing), + slog.Any("candidate_action", action), + ) + } + m[key] = preferAction(existing, action) + } + return slices.Collect(maps.Values(m)) +} + func actionsConflict(existing *policy.Action, candidate *policy.Action) bool { if existing == nil || candidate == nil { return false diff --git a/test/start-up-with-containers/action.yaml b/test/start-up-with-containers/action.yaml index 1babf8e737..acc8f85e86 100644 --- a/test/start-up-with-containers/action.yaml +++ b/test/start-up-with-containers/action.yaml @@ -23,6 +23,10 @@ inputs: default: "text" description: 'Log format type (text, json)' required: false + provision-policy-fixtures: + default: "true" + description: 'Whether to provision fixture policy data after bootstrapping the platform' + required: false outputs: platform-working-dir: @@ -211,6 +215,7 @@ runs: run: go run ./service provision keycloak working-directory: otdf-test-platform - name: Provision test fixture policy + if: ${{ inputs.provision-policy-fixtures == 'true' }} shell: bash run: go run ./service provision fixtures working-directory: otdf-test-platform diff --git a/tests-bdd/cukes/steps_obligations.go b/tests-bdd/cukes/steps_obligations.go index 710d96e226..07e1622fe0 100644 --- a/tests-bdd/cukes/steps_obligations.go +++ b/tests-bdd/cukes/steps_obligations.go @@ -21,6 +21,7 @@ const ( obligationTriggerResponseKey = "obligationTriggerResponse" multiDecisionResponseKey = "multiDecisionResponse" valuesKey = "values" + namespaceIDKey = "namespace_id" ) // Step: I send a request to create an obligation with table @@ -43,10 +44,10 @@ func (s *ObligationsStepDefinitions) iSendARequestToCreateAnObligationWith(ctx c for ci, c := range r.Cells { switch cellIndexMap[ci] { - case "namespace_id": + case namespaceIDKey: nsID, ok := scenarioContext.GetObject(strings.TrimSpace(c.Value)).(string) if !ok { - return ctx, fmt.Errorf("namespace_id %s not found", c.Value) + return ctx, fmt.Errorf("%s %s not found", namespaceIDKey, c.Value) } req.NamespaceId = nsID case "name":