diff --git a/.github/workflows/boxel-test-subset.yaml b/.github/workflows/boxel-test-subset.yaml new file mode 100644 index 00000000..3cb5aba6 --- /dev/null +++ b/.github/workflows/boxel-test-subset.yaml @@ -0,0 +1,240 @@ +name: Boxel Test Subset + +# The boxel monorepo pins a subset of this repo's system-level definitions +# (packages/catalog/test-subset.json there) and tests the platform against it. +# The deployed catalog realm serves this repo's main, so a change to one of +# those files has to keep boxel's tests passing before it merges. This +# workflow runs the boxel tests the manifest names against this checkout +# whenever a change touches a subset file. +# +# A pull request is tested against the boxel branch of the same name when one +# exists, so a catalog change and the boxel change that depends on it can be +# validated together before either merges; otherwise against boxel main. +# A manual run can name any boxel ref. +# +# It is a no-op when the boxel checkout has no manifest, and for pull +# requests that touch no subset file. + +on: + workflow_dispatch: + inputs: + boxel_ref: + description: boxel ref to test against (branch, tag or sha) + required: false + default: main + push: + branches: [main] + pull_request: + +permissions: + contents: read + pull-requests: read + +concurrency: + group: boxel-test-subset-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + scope: + name: Decide whether the boxel test subset is touched + runs-on: ubuntu-latest + outputs: + boxel_ref: ${{ steps.ref.outputs.boxel_ref }} + run: ${{ steps.scope.outputs.run }} + host_filters: ${{ steps.scope.outputs.host_filters }} + realm_server_files: ${{ steps.scope.outputs.realm_server_files }} + steps: + - name: Pick the boxel ref + id: ref + env: + REQUESTED: ${{ inputs.boxel_ref }} + BRANCH: ${{ github.head_ref || github.ref_name }} + run: | + ref="$REQUESTED" + if [ -z "$ref" ]; then + if git ls-remote --exit-code --heads https://github.com/cardstack/boxel.git "$BRANCH" >/dev/null; then + ref="$BRANCH" + else + ref=main + fi + fi + echo "Testing against boxel@$ref" + echo "boxel_ref=$ref" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 + with: + repository: cardstack/boxel + ref: ${{ steps.ref.outputs.boxel_ref }} + sparse-checkout: packages/catalog + path: boxel + + - name: Decide scope + id: scope + env: + GH_TOKEN: ${{ github.token }} + run: | + manifest=boxel/packages/catalog/test-subset.json + if [ ! -f "$manifest" ]; then + echo "::notice::boxel@${{ steps.ref.outputs.boxel_ref }} has no catalog test subset; nothing to run" + echo "run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + run=true + if [ "${{ github.event_name }}" != "workflow_dispatch" ]; then + # Both file listings are capped (3,000 files for a PR, 300 for a + # compare), and a full list may hide a subset file, so a full + # list counts as touching the subset. + # A rename lists its old path only as previous_filename; include it + # so moving a subset file away runs the tests, whose sync then + # fails on the missing file. + if [ "${{ github.event_name }}" = "pull_request" ]; then + gh api "repos/$GITHUB_REPOSITORY/pulls/${{ github.event.pull_request.number }}/files" \ + --paginate --jq '.[] | .filename, (.previous_filename // empty)' > changed.txt + listed=$(gh api "repos/$GITHUB_REPOSITORY/pulls/${{ github.event.pull_request.number }}" --jq .changed_files) + cap=3000 + else + gh api "repos/$GITHUB_REPOSITORY/compare/${{ github.event.before }}...${{ github.sha }}" > compare.json + jq -r '.files[] | .filename, (.previous_filename // empty)' compare.json > changed.txt + listed=$(jq '.files | length' compare.json) + cap=300 + fi + jq -r '.files[].path' "$manifest" > subset.txt + if [ "$listed" -ge "$cap" ]; then + echo "The change lists $listed files, at the API's cap, so it may touch a subset file" + elif grep -qxFf subset.txt changed.txt; then + echo "Touched subset files:"; grep -xFf subset.txt changed.txt + else + echo "No subset file touched" + run=false + fi + fi + echo "run=$run" >> "$GITHUB_OUTPUT" + echo "host_filters=$(jq -c '.tests.host // []' "$manifest")" >> "$GITHUB_OUTPUT" + echo "realm_server_files=$(jq -r '.tests.realmServer // [] | join(",")' "$manifest")" >> "$GITHUB_OUTPUT" + + host: + name: Boxel host tests against this catalog + needs: scope + if: needs.scope.outputs.run == 'true' && needs.scope.outputs.host_filters != '[]' + runs-on: ubuntu-latest + env: + # The checkout below is the exact commit under test; keep + # catalog-update.sh from pulling it elsewhere, and serve the subset + # definitions from it rather than from boxel's pin. + SKIP_CATALOG_UPDATE: "1" + CATALOG_TEST_SUBSET_SOURCE: packages/catalog/contents + # Serve only the subset, as boxel's own host CI does, rather than + # indexing the whole catalog. + CATALOG_SOURCE: test-subset + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 + with: + repository: cardstack/boxel + ref: ${{ needs.scope.outputs.boxel_ref }} + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 + with: + path: packages/catalog/contents + + - uses: ./.github/actions/init + + - name: Build common dependencies + run: pnpm run build-common-deps + + - name: Build Host for tests + run: NODE_OPTIONS='--max-old-space-size=8192' pnpm build + working-directory: packages/host + + - name: Disable TCP/UDP network offloading + run: sudo ethtool -K eth0 tx off rx off + + - name: Install D-Bus helpers + run: | + sudo apt-get update + sudo apt-get install -y dbus-x11 upower + sudo service dbus restart + sudo service upower restart + + - name: Start test services + run: mise run test-services:host | tee -a /tmp/server.log & + + - name: Create realm users + run: pnpm register-realm-users + working-directory: packages/matrix + + - name: Wait for the stack + run: | + for url in \ + 'https://localhost:4201/base/_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson' \ + 'https://localhost:4201/catalog/_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson' \ + 'https://localhost:4202/test/_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson' \ + 'http://localhost:8008'; do + timeout 900 bash -c "until curl -skf '$url' >/dev/null; do sleep 5; done" + done + + - name: Host tests the subset feeds + working-directory: packages/host + env: + DBUS_SYSTEM_BUS_ADDRESS: unix:path=/run/dbus/system_bus_socket + HOST_FILTERS: ${{ needs.scope.outputs.host_filters }} + run: | + status=0 + while IFS= read -r filter; do + echo "::group::ember test --filter \"$filter\"" + dbus-run-session -- pnpm exec ember test --path ./dist --filter "$filter" || status=1 + echo "::endgroup::" + done < <(jq -r '.[]' <<<"$HOST_FILTERS") + exit $status + + - name: Print realm server logs + if: ${{ !cancelled() }} + run: cat /tmp/server.log + + realm-server: + name: Boxel realm-server tests against this catalog + needs: scope + if: needs.scope.outputs.run == 'true' && needs.scope.outputs.realm_server_files != '' + runs-on: ubuntu-latest + env: + SKIP_CATALOG_UPDATE: "1" + CATALOG_TEST_SUBSET_SOURCE: packages/catalog/contents + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 + with: + repository: cardstack/boxel + ref: ${{ needs.scope.outputs.boxel_ref }} + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 + with: + path: packages/catalog/contents + + - uses: ./.github/actions/init + + - name: Build common dependencies + run: pnpm run build-common-deps + + - name: Build Host for the prerenderer + run: NODE_OPTIONS='--max-old-space-size=8192' pnpm build + working-directory: packages/host + + - name: Warm test Docker images + uses: ./.github/actions/warm-test-images + + # test-services:realm-server serves the subset as the catalog realm; + # CATALOG_TEST_SUBSET_SOURCE makes the subset this checkout's copy. + - name: Start test services + run: mise run test-services:realm-server | tee -a /tmp/server.log & + + - name: Create realm users + run: pnpm register-realm-users + working-directory: packages/matrix + + - name: Realm-server tests the subset feeds + run: pnpm test:wait-for-servers + working-directory: packages/realm-server + env: + TEST_FILES: ${{ needs.scope.outputs.realm_server_files }} + + - name: Print realm server logs + if: ${{ !cancelled() }} + run: cat /tmp/server.log diff --git a/fields/policy-predicate/policy-predicate.gts b/fields/policy-predicate/policy-predicate.gts new file mode 100644 index 00000000..05f31e1e --- /dev/null +++ b/fields/policy-predicate/policy-predicate.gts @@ -0,0 +1,161 @@ +import { + Component, + FieldDef, + primitive, +} from 'https://cardstack.com/base/card-api'; +import { + fieldSerializer, + type PolicyPredicate, +} from '@cardstack/runtime-common'; +import { BoxelInput, Button, Switch } from '@cardstack/boxel-ui/components'; +import { on } from '@ember/modifier'; +import { not } from '@cardstack/boxel-ui/helpers'; +import CodeIcon from '@cardstack/boxel-icons/code'; + +// A policy grant's `where` condition: a BXL boolean expression over the caller +// and the target, stored as source. The value is `{ source, snapshot }`; in a +// policy document it is a bare string of BXL, or `{ bxl, snapshot: true }` for +// a predicate that deliberately reads a snapshot value. The `policy-predicate` +// serializer owns both shapes. +// +// Nothing here parses, validates or evaluates the source; it is stored and +// shown exactly as written. + +class View extends Component { + +} + +class Edit extends Component { + // Emptying the text keeps the predicate, as an empty one: an author + // rewriting a condition passes through the empty state, and an unset + // predicate is an unconditional grant. Removing the condition is its own + // explicit action. + private setSource = (source: string) => { + this.args.set({ source, snapshot: this.args.model?.snapshot ?? false }); + }; + + private removeCondition = () => { + this.args.set(null); + }; + + private setSnapshot = (snapshot: boolean) => { + if (!this.args.model) { + return; + } + this.args.set({ source: this.args.model.source, snapshot }); + }; + + +} + +export default class PolicyPredicateField extends FieldDef { + static displayName = 'Policy Predicate'; + static icon = CodeIcon; + static [primitive]: PolicyPredicate; + static [fieldSerializer] = 'policy-predicate'; + static embedded = View; + static atom = View; + static fitted = View; + static edit = Edit; +} diff --git a/realm-policy/realm-policy.gts b/realm-policy/realm-policy.gts new file mode 100644 index 00000000..55b4f984 --- /dev/null +++ b/realm-policy/realm-policy.gts @@ -0,0 +1,260 @@ +import { + CardDef, + Component, + FieldDef, + contains, + containsMany, + field, +} from 'https://cardstack.com/base/card-api'; +import CodeRefField from 'https://cardstack.com/base/code-ref'; +import PolicyPredicateField from '@cardstack/catalog/fields/policy-predicate/policy-predicate'; +import StringField from 'https://cardstack.com/base/string'; +import ShieldCheckIcon from '@cardstack/boxel-icons/shield-check'; + +// A realm's operation policy: which callers may invoke which operations on +// which card types, beyond what the realm's own read/write permissions allow. +// +// A policy only ever widens access. It is a union of grants — a caller may +// invoke an operation on a target if the realm already permits it, or if any +// rule whose `targetType` matches the target (or one of its ancestors) holds a +// grant for that operation whose `where` predicate, when present, is true. +// Rule order carries no meaning. +// +// These definitions only describe a policy; nothing in them evaluates one. + +export class OperationGrant extends FieldDef { + static displayName = 'Operation Grant'; + + // The operation name as a caller invokes it — a base operation such as + // `read` or `update`, or a name a card declares. A grant on a named + // operation does not grant the base operation it is built on. + @field operation = contains(StringField); + // A BXL boolean expression over the caller and the target. Absent means + // the grant is unconditional. + @field where = contains(PolicyPredicateField); + + static embedded = class Embedded extends Component { + + }; +} + +export class PolicyRule extends FieldDef { + static displayName = 'Policy Rule'; + + // The card type this rule governs. It also governs that type's subtypes. + @field targetType = contains(CodeRefField); + @field grants = containsMany(OperationGrant); + + static embedded = class Embedded extends Component { + + }; +} + +export class RealmPolicy extends CardDef { + static displayName = 'Realm Policy'; + static icon = ShieldCheckIcon; + + @field rules = containsMany(PolicyRule); + + static isolated = class Isolated extends Component { + + }; + + static embedded = class Embedded extends Component { + + }; +}