diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index a33f2d19..5fac3ca1 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -4,6 +4,10 @@ name: go # request and on main. tools/noproxygate's test is the no-proxy gate: it # type-checks every module and refuses requests, dials and subprocesses that # bypass host.HTTPTransport and host.DialOptions. +# tools/packagegate's test is the package gate: every module a run admits +# ships its package manifest, and its contracts match what `codefly generate +# contracts` writes from the services' sources, and every method a host +# fronting the module receives declares its policy. on: pull_request: diff --git a/README.md b/README.md index 6fec267b..d3c39008 100644 --- a/README.md +++ b/README.md @@ -1042,7 +1042,9 @@ the key again at the next renewal, a restarted hop re-registers the same entry, and a key no running hop signs with is withdrawn without being named. The seed never leaves the hop; nothing about the hop is configured on the authority. It is additive too. The local authority implements it; `accounts` implementing the -same registration is follow-up work in the host. +same registration is follow-up work in the host. The hop calls it, so the hop +requires `>=0.3.3 <0.4.0`: a host below 0.3.3 is refused at render instead of +leaving every module's verifier to refuse every call. The 0.3.1 authority version is an additive pre-stable revision selected for this refresh so existing `>=0.2.0 <0.4.0` consumers admit it. It retains 0.3.0's diff --git a/modules/authority/contracts.codefly.yaml b/modules/authority/contracts.codefly.yaml new file mode 100644 index 00000000..c8b360c1 --- /dev/null +++ b/modules/authority/contracts.codefly.yaml @@ -0,0 +1,8 @@ +# Publication configuration for `codefly generate contracts`: the local +# authority's grpc listener serves its own LocalService and the host interfaces +# (authority, composition, installation, session) plus the artifact service, so +# the catalog carries what that listener serves. The hop's endpoints serve one +# package each and need no inventory. +schema: codefly/module-contracts-config/v1 +surfaces: + local: contract-surfaces.json diff --git a/modules/authority/contracts/api/catalog.codefly.json b/modules/authority/contracts/api/catalog.codefly.json new file mode 100644 index 00000000..e9fd90d6 --- /dev/null +++ b/modules/authority/contracts/api/catalog.codefly.json @@ -0,0 +1,135 @@ +{ + "schema": "codefly/module-api-contracts/v1", + "package": "codefly/authority", + "version": "0.1.0", + "endpoints": [ + { + "service": "hop", + "endpoint": "grpc", + "api": "grpc", + "kind": "protobuf", + "package": "api", + "path": "contracts/api/hop/grpc/contract.binpb", + "digest": "sha256:a85d37ee2ac63dcaa92f00ce43e9d7ca180d3f76006739b47716b5dcd1b1f8e2", + "services": [ + { + "name": "HopService", + "fullName": "api.HopService", + "procedures": [ + "/api.HopService/Version" + ] + } + ] + }, + { + "service": "hop", + "endpoint": "http", + "api": "http", + "kind": "openapi", + "package": "api.proto", + "path": "contracts/api/hop/http/openapi.json", + "digest": "sha256:8907423351d7c8d1970bab6e2edbe0905b3c613dbfebbfad628f87f28701dfef", + "routes": [ + { + "method": "GET", + "path": "/version" + } + ] + }, + { + "service": "local", + "endpoint": "grpc", + "api": "grpc", + "kind": "protobuf", + "package": "", + "path": "contracts/api/local/grpc/contract.binpb", + "digest": "sha256:1c51ec903d141b1df6c0eff0fcaa10aa48b6633e0753a13718bef83d086dc0cb", + "services": [ + { + "name": "LocalService", + "fullName": "api.LocalService", + "procedures": [ + "/api.LocalService/Version" + ] + }, + { + "name": "ArtifactService", + "fullName": "host.artifact.v1.ArtifactService", + "procedures": [ + "/host.artifact.v1.ArtifactService/Approve", + "/host.artifact.v1.ArtifactService/Authorize", + "/host.artifact.v1.ArtifactService/Revoke" + ] + }, + { + "name": "AuthorityService", + "fullName": "host.authority.v1.AuthorityService", + "procedures": [ + "/host.authority.v1.AuthorityService/AttachResource", + "/host.authority.v1.AuthorityService/AuthorizeTransfer", + "/host.authority.v1.AuthorityService/Check", + "/host.authority.v1.AuthorityService/Delegate", + "/host.authority.v1.AuthorityService/DetachResource", + "/host.authority.v1.AuthorityService/GetDelegation", + "/host.authority.v1.AuthorityService/Issue", + "/host.authority.v1.AuthorityService/Keys", + "/host.authority.v1.AuthorityService/ListResources", + "/host.authority.v1.AuthorityService/RegisterAdmissionKey", + "/host.authority.v1.AuthorityService/RevokeDelegation" + ] + }, + { + "name": "CompositionService", + "fullName": "host.composition.v1.CompositionService", + "procedures": [ + "/host.composition.v1.CompositionService/ApplyManifest" + ] + }, + { + "name": "InstallationService", + "fullName": "host.installation.v1.InstallationService", + "procedures": [ + "/host.installation.v1.InstallationService/GrantRole", + "/host.installation.v1.InstallationService/Install", + "/host.installation.v1.InstallationService/List", + "/host.installation.v1.InstallationService/ListRoleGrants", + "/host.installation.v1.InstallationService/Review", + "/host.installation.v1.InstallationService/RevokeRole" + ] + }, + { + "name": "OfferService", + "fullName": "host.installation.v1.OfferService", + "procedures": [ + "/host.installation.v1.OfferService/ListOffers", + "/host.installation.v1.OfferService/ReviewOffer" + ] + }, + { + "name": "SessionService", + "fullName": "host.session.v1.SessionService", + "procedures": [ + "/host.session.v1.SessionService/Current", + "/host.session.v1.SessionService/ListFixturePeople", + "/host.session.v1.SessionService/SignIn" + ] + } + ] + }, + { + "service": "local", + "endpoint": "rest", + "api": "rest", + "kind": "openapi", + "package": "api.proto", + "path": "contracts/api/local/rest/openapi.json", + "digest": "sha256:dc9c7adab86fcf9878f145988c5a5eddf509ac9f59980f0f660b804c91258796", + "routes": [ + { + "method": "GET", + "path": "/version" + } + ] + } + ] +} diff --git a/modules/authority/contracts/api/hop/grpc/contract.binpb b/modules/authority/contracts/api/hop/grpc/contract.binpb new file mode 100644 index 00000000..59db7f38 Binary files /dev/null and b/modules/authority/contracts/api/hop/grpc/contract.binpb differ diff --git a/modules/authority/contracts/api/hop/grpc/proto/api.proto b/modules/authority/contracts/api/hop/grpc/proto/api.proto new file mode 100644 index 00000000..61d82090 --- /dev/null +++ b/modules/authority/contracts/api/hop/grpc/proto/api.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; +package api; + +// Keep the Go import path stable while the protobuf package remains free to +// evolve into a versioned wire namespace (for example coordination.v1). +option go_package = "hop/pkg/gen;gen"; + +import "authority/hop/v1/policy.proto"; +import "google/api/annotations.proto"; + +message VersionRequest { +} + +message VersionResponse { + string version = 1; +} + +service HopService { + // Version is the hop's own method. A caller the authority checked may read + // it; the hop serves it without a scope, as the console serves its Version. + rpc Version(VersionRequest) returns (VersionResponse) { + option (authority.hop.v1.policy) = {any_checked_caller: true}; + option (google.api.http) = { + get: "/version" + }; + } +} diff --git a/modules/authority/contracts/api/hop/grpc/proto/authority/VENDORED.md b/modules/authority/contracts/api/hop/grpc/proto/authority/VENDORED.md new file mode 100644 index 00000000..560aa696 --- /dev/null +++ b/modules/authority/contracts/api/hop/grpc/proto/authority/VENDORED.md @@ -0,0 +1,11 @@ +# Vendored: the authority's method policy + +`hop/v1/policy.proto` is a byte-for-byte copy of +`libraries/hop/proto/authority/hop/v1/policy.proto` in this repository, so the +hop's proto can mark its own methods with the option. + +This is a stopgap. No Codefly library exports a proto yet, so a module cannot +import another's option through the CLI, and every module in the fleet copies +the option protos it uses by hand. The real fix, codefly-dev/cli#1130, is the +CLI importing a proto a library exports. Until then `code/vendored_test.go` +fails when this copy drifts from its source. diff --git a/modules/authority/contracts/api/hop/grpc/proto/authority/hop/v1/policy.proto b/modules/authority/contracts/api/hop/grpc/proto/authority/hop/v1/policy.proto new file mode 100644 index 00000000..b7c93184 --- /dev/null +++ b/modules/authority/contracts/api/hop/grpc/proto/authority/hop/v1/policy.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; + +// The authority's declaration of what a call to one method must hold. A module +// marks each method it serves; the hop in front of the module reads the marks +// and lets a call through only when the authority checked its Work Context and +// the call holds what its method declares. +package authority.hop.v1; + +import "google/protobuf/descriptor.proto"; + +option go_package = "github.com/codefly-dev/authority/libraries/hop/go/gen/authority/hop/v1;hopv1"; + +// Policy is one method's declaration. Exactly one of its two fields is set: a +// method that requires nothing says so with any_checked_caller, because an +// absent declaration and "nothing is required" must not look the same. The hop +// refuses every call to a method that declares neither, or both. +message Policy { + // Requires lists what the call must hold, every entry: each action on its + // resource kind, in the call's effective authority. + repeated Requirement requires = 1; + // AnyCheckedCaller lets through any call whose Work Context the authority + // checked, holding nothing in particular. + bool any_checked_caller = 2; +} + +// Requirement is actions on one resource kind, spelled in the kind's own +// vocabulary as the module's contract declares it. +message Requirement { + // ResourceKind is the kind, as the module's scope ceilings name it. + string resource_kind = 1; + // Actions are each required. + repeated string actions = 2; +} + +extend google.protobuf.MethodOptions { + // policy is the method's declaration. + Policy policy = 51100; +} diff --git a/modules/authority/contracts/api/hop/grpc/proto/buf.gen.yaml b/modules/authority/contracts/api/hop/grpc/proto/buf.gen.yaml new file mode 100644 index 00000000..d2a60c2d --- /dev/null +++ b/modules/authority/contracts/api/hop/grpc/proto/buf.gen.yaml @@ -0,0 +1,29 @@ +# The service's own bindings: api.proto alone, named as the one input so the +# agent's sync and `codefly generate proto` write the same files; the hop's +# policy option (authority/hop/v1, vendored to compile) is an import whose +# bindings the hop library publishes. +# +# Go package identity is explicit in api.proto. Managed rewriting couples Go +# package names to protobuf namespaces and breaks versioned wire packages. +version: v2 +managed: + enabled: false +inputs: + - directory: . + paths: + - api.proto +plugins: + - local: protoc-gen-go + out: ../code/pkg/gen + opt: paths=source_relative + - local: protoc-gen-go-grpc + out: ../code/pkg/gen + opt: paths=source_relative + - local: protoc-gen-grpc-gateway + out: ../code/pkg/gen + opt: paths=source_relative + - local: protoc-gen-connect-go + out: ../code/pkg/gen + opt: paths=source_relative + - local: protoc-gen-openapiv2 + out: ../openapi diff --git a/modules/authority/contracts/api/hop/grpc/proto/buf.lock b/modules/authority/contracts/api/hop/grpc/proto/buf.lock new file mode 100644 index 00000000..225f0505 --- /dev/null +++ b/modules/authority/contracts/api/hop/grpc/proto/buf.lock @@ -0,0 +1,8 @@ +# Generated by buf. DO NOT EDIT. +version: v1 +deps: + - remote: buf.build + owner: googleapis + repository: googleapis + commit: 8ac61b9e502343289c8c42b17aa5b245 + digest: shake256:30840693e3b5edac0f461e7168da1057b36a5df75d2d0c6fd920f8442a2f51afb22c955ad5afc25a1394f6e2de65d623844594a37d9f9ce093c48d6afe6a3e55 diff --git a/modules/authority/contracts/api/hop/grpc/proto/buf.yaml b/modules/authority/contracts/api/hop/grpc/proto/buf.yaml new file mode 100644 index 00000000..bfb72f12 --- /dev/null +++ b/modules/authority/contracts/api/hop/grpc/proto/buf.yaml @@ -0,0 +1,7 @@ +version: v1 +deps: + - buf.build/googleapis/googleapis +lint: + use: + - DEFAULT + - COMMENTS diff --git a/modules/authority/contracts/api/hop/http/openapi.json b/modules/authority/contracts/api/hop/http/openapi.json new file mode 100644 index 00000000..fc37cb67 --- /dev/null +++ b/modules/authority/contracts/api/hop/http/openapi.json @@ -0,0 +1,81 @@ +{ + "swagger": "2.0", + "info": { + "title": "api.proto", + "version": "version not set" + }, + "tags": [ + { + "name": "HopService" + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/version": { + "get": { + "summary": "Version is the hop's own method. A caller the authority checked may read\nit; the hop serves it without a scope, as the console serves its Version.", + "operationId": "HopService_Version", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/apiVersionResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "tags": [ + "HopService" + ] + } + } + }, + "definitions": { + "apiVersionResponse": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + } + }, + "protobufAny": { + "type": "object", + "properties": { + "@type": { + "type": "string" + } + }, + "additionalProperties": {} + }, + "rpcStatus": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/protobufAny" + } + } + } + } + } +} diff --git a/modules/authority/contracts/api/local/grpc/contract.binpb b/modules/authority/contracts/api/local/grpc/contract.binpb new file mode 100644 index 00000000..64bdfd23 Binary files /dev/null and b/modules/authority/contracts/api/local/grpc/contract.binpb differ diff --git a/modules/authority/contracts/api/local/grpc/proto/api.proto b/modules/authority/contracts/api/local/grpc/proto/api.proto new file mode 100644 index 00000000..ceac236a --- /dev/null +++ b/modules/authority/contracts/api/local/grpc/proto/api.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; +package api; + +// Keep the Go import path stable while the protobuf package remains free to +// evolve into a versioned wire namespace (for example coordination.v1). +option go_package = "gateway/pkg/gen;gen"; + +import "google/api/annotations.proto"; + +message VersionRequest { +} + +message VersionResponse { + string version = 1; +} + +service LocalService { + rpc Version(VersionRequest) returns (VersionResponse) { + option (google.api.http) = { + get: "/version" + }; + } +} diff --git a/modules/authority/contracts/api/local/grpc/proto/buf.gen.yaml b/modules/authority/contracts/api/local/grpc/proto/buf.gen.yaml new file mode 100644 index 00000000..75b595bc --- /dev/null +++ b/modules/authority/contracts/api/local/grpc/proto/buf.gen.yaml @@ -0,0 +1,26 @@ +version: v1 +# Go package identity is explicit in api.proto. Managed rewriting couples Go +# package names to protobuf namespaces and breaks versioned wire packages. +managed: + enabled: false +plugins: + - name: go + path: protoc-gen-go + out: ../code/pkg/gen + opt: paths=source_relative + - name: go-grpc + path: protoc-gen-go-grpc + out: ../code/pkg/gen + opt: paths=source_relative + - name: grpc-gateway + path: protoc-gen-grpc-gateway + out: ../code/pkg/gen + opt: + - paths=source_relative + - name: connect-go + path: protoc-gen-connect-go + out: ../code/pkg/gen + opt: paths=source_relative + - name: openapiv2 + path: protoc-gen-openapiv2 + out: ../openapi diff --git a/modules/authority/contracts/api/local/grpc/proto/buf.lock b/modules/authority/contracts/api/local/grpc/proto/buf.lock new file mode 100644 index 00000000..67b5b199 --- /dev/null +++ b/modules/authority/contracts/api/local/grpc/proto/buf.lock @@ -0,0 +1,13 @@ +# Generated by buf. DO NOT EDIT. +version: v1 +deps: + - remote: buf.build + owner: bufbuild + repository: protovalidate + commit: 511051f7f4374c3ca873b53ae68a9288 + digest: shake256:b91195e22365fb4736bb8ace3d463030446c7e2fc347af38be074bdc335531150ec5c648f9f75c7993199e2cb85c62c21928f1b9113175c32348ad5f453ef99f + - remote: buf.build + owner: googleapis + repository: googleapis + commit: 8ac61b9e502343289c8c42b17aa5b245 + digest: shake256:30840693e3b5edac0f461e7168da1057b36a5df75d2d0c6fd920f8442a2f51afb22c955ad5afc25a1394f6e2de65d623844594a37d9f9ce093c48d6afe6a3e55 diff --git a/modules/authority/contracts/api/local/grpc/proto/buf.yaml b/modules/authority/contracts/api/local/grpc/proto/buf.yaml new file mode 100644 index 00000000..48ccabfd --- /dev/null +++ b/modules/authority/contracts/api/local/grpc/proto/buf.yaml @@ -0,0 +1,8 @@ +version: v1 +deps: + - buf.build/googleapis/googleapis + - buf.build/bufbuild/protovalidate +lint: + use: + - DEFAULT + - COMMENTS diff --git a/modules/authority/contracts/api/local/grpc/proto/host/artifact/v1/artifact.proto b/modules/authority/contracts/api/local/grpc/proto/host/artifact/v1/artifact.proto new file mode 100644 index 00000000..52019782 --- /dev/null +++ b/modules/authority/contracts/api/local/grpc/proto/host/artifact/v1/artifact.proto @@ -0,0 +1,87 @@ +syntax = "proto3"; +// The artifact interface: a person's consent to an exact authored artifact a +// module runs (an agent's configuration, an intent version), for one +// installation, under one policy. A policy is a resource kind: approving +// requires its `activate` action at the module, running requires its `run`. +// Consent is given in person and read back at every run; it is never a +// credential to execute or read anything. +package host.artifact.v1; + +option go_package = "gateway/pkg/gen/host/artifact/v1;artifactv1"; + +// ArtifactService is implemented by the host and by the local authority. +service ArtifactService { + // Approve records the person's consent to the identity for the installation + // under the policy. The person is present: the parent is theirs, addressed + // to the asking module, with no module acting between, and holds the policy + // kind's activate action there. An identity already approved answers the + // same approval; one whose approval was revoked is refused. Consent is + // durable: the host keeps it in its store, so it outlives the process that + // recorded it and answers every run until it is revoked. + rpc Approve(ArtifactRequest) returns (Approval); + // Authorize answers the standing approval of the identity, for a run: the + // parent may be delegated and holds the policy kind's run action. It never + // creates consent. + rpc Authorize(ArtifactRequest) returns (Approval); + // Revoke withdraws an approval, permanently, for a person present holding + // the policy kind's activate action. + rpc Revoke(RevokeRequest) returns (Approval); +} + +// Contract is one contract the artifact is compiled against. +message Contract { + string kind = 1; + string name = 2; + string digest = 3; +} + +// Identity is the artifact's complete canonical identity, as the module's +// compiler exports it: references and digests, never authored content. +message Identity { + // The schema the subject is written in, which owns its canonical bytes. + string schema = 1; + // The source the artifact belongs to. + string source = 2; + // The canonical subject. + bytes subject = 3; + // The contracts it is compiled against. + repeated Contract contracts = 4; + // The revision the module expects the approval at. + int64 expected_revision = 5; +} + +// ArtifactRequest asks about one identity, for one installation, under one policy. +message ArtifactRequest { + // The Work Context the module was presented, addressed to it: whose consent + // this is, and what they hold there. + string parent_token = 1; + // The module asking, by its workload credential. + string actor_credential = 2; + // The installation the artifact runs in. + string installation_id = 3; + // The policy: a resource kind at the module. + string policy = 4; + // The artifact. + Identity identity = 5; +} + +// RevokeRequest withdraws one approval. +message RevokeRequest { + string parent_token = 1; + string actor_credential = 2; + string installation_id = 3; + // The policy the approval was given under. + string policy = 4; + // The approval, by its id. + string approval_id = 5; +} + +// Approval is the host's exact consent reference. +message Approval { + // host/approved-artifacts/. + string qualified_name = 1; + // sha256: of the identity approved. + string digest = 2; + // The revision the approval was given at. + int64 expected_revision = 3; +} diff --git a/modules/authority/contracts/api/local/grpc/proto/host/authority/v1/authority.proto b/modules/authority/contracts/api/local/grpc/proto/host/authority/v1/authority.proto new file mode 100644 index 00000000..104828ea --- /dev/null +++ b/modules/authority/contracts/api/local/grpc/proto/host/authority/v1/authority.proto @@ -0,0 +1,416 @@ +syntax = "proto3"; +// The authority interface: what a module or solution calls on its host to be +// issued a Work Context, to have one checked, and to learn the keys it was +// signed with, and to ask which resources of a kind it covers. Every former spelling (start task, start +// installation task, exchange audience, start child, renew, the module broker's +// mints) is Issue with a different `as`. +package host.authority.v1; + +option go_package = "gateway/pkg/gen/host/authority/v1;authorityv1"; + +import "google/api/annotations.proto"; +import "host/authority/v1/work_context.proto"; + +// AuthorityService is implemented by the host (accounts) and by the local authority. +service AuthorityService { + // Issue signs a Work Context for one owner shape, one audience and the scopes + // asked for, bounded by what the owner holds and what the audience declared. + // Served over REST too (POST /v1/authority:issue), for a module with no + // generated client of its own, as a Python service: the same request and + // answer, in JSON. + rpc Issue(IssueRequest) returns (IssueResponse) { + option (google.api.http) = {post: "/v1/authority:issue" body: "*"}; + } + // Check verifies a presented Work Context for the audience the caller is, + // re-checking the authorization revision and the seal live. + rpc Check(CheckRequest) returns (CheckResponse) { + option (google.api.http) = {post: "/v1/authority:check" body: "*"}; + } + // Keys returns the issuer and the public keys a verifier pins: the Work + // Context keys, and the hop admission keys a module behind the hop checks + // each call's admission by. + rpc Keys(KeysRequest) returns (KeysResponse); + // RegisterAdmissionKey is how the hop tells the authority the public key it + // signs call admissions with, so Keys publishes it in admission_keys. The + // caller is the hop, proving it with its workload credential; any other + // workload is refused by name. Only the public half travels: the hop's seed + // never leaves it. Registering the same key again is idempotent and renews + // it, and a registration lapses at expires_at_unix unless renewed, so a hop + // renews while it runs (a restarted authority learns the key again at the + // next renewal) and a key no running hop signs with any more is withdrawn + // without being named. While a rotation overlaps, the retiring and the new + // key are both registered, and both published. + rpc RegisterAdmissionKey(RegisterAdmissionKeyRequest) returns (RegisterAdmissionKeyResponse); + // ListResources answers which resources of one kind a checked capability may + // act on with one action, decided by the authority at the time of the call + // rather than carried in the capability. A module maps the answer onto its + // own resources; the authority knows them only by id. + rpc ListResources(ListResourcesRequest) returns (ListResourcesResponse); + // AttachResource records that one resource now belongs to an installation, + // so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. The caller is the kind's one + // serving module (a kind two composed modules declare attaches nothing), + // proving itself with its workload credential, and presents a capability + // addressed to it, at the organisation's current authorization revision, + // holding "create" on the whole kind for the newly created resource. An + // owner-selected create must select this installation's principal. Admin or + // read alone attaches nothing: an existing resource changes owner through + // AuthorizeTransfer, never through an unrestricted administrative capability. + // A resource has one owner in an organisation: an id + // another installation owns is refused. A store whose attach is refused fails + // or rolls back the create it made, and detaches what it deletes + // (DetachResource). The owning installation is read from the capability's + // verified provenance and never named by the caller: the installation itself + // when it is the owner, the installed solution when it is the caller, and + // each solution in its actor chain; exactly one must result. Ids are opaque + // and compared byte for byte; an id is refused when it is empty, carries + // surrounding whitespace, a NUL, CR, LF, tab or "*", or exceeds 128 bytes. + // Owned ids travel in every capability holding them, so an installation owns + // at most 32 of one kind at a time, refused by name past it. Attaching only + // widens what an owned scope covers, so it moves no authorization revision; + // a reinstalled installation owns nothing. + rpc AttachResource(AttachResourceRequest) returns (AttachResourceResponse); + // DetachResource releases one resource from whichever installation of the + // organisation owns it, when the kind's one serving module deletes it, so the + // id no longer counts toward the owner's bound and may be attached again. + // It narrows the owner's roles, so the organisation's authorization revision + // moves. Detaching an id no installation owns answers no installation. + rpc DetachResource(DetachResourceRequest) returns (DetachResourceResponse); + // AuthorizeTransfer decides whether an existing resource may join an + // installed solution's principal. The caller is the kind's one serving + // module, which attests the resource exists and who owns it now + // (from_owner_id), presenting the capability of the person asking: issued + // through the target principal's installation and addressed to the caller. + // It is allowed when the current owner is the organization or a person of + // it (never another solution principal, another organization or an unknown + // owner), the person holds admin on that resource from their own + // organization grants, and the person's current roles on the principal hold + // write on the kind, all read live. An installation acting for itself never + // transfers. After authorization succeeds, the authority records the id in + // the target installation's owned set in the same write transaction, subject + // to the same exclusivity, id and 32-id bounds as AttachResource. Revision, + // provenance, serving module and active tenant installation are checked in + // that transaction. A refusal records nothing and names its reason. The + // store then records the principal as owner; the original creator stays the + // author. The store must complete that change or compensate with DetachResource + // before exposing the resource. No separate admin attachment path exists. + rpc AuthorizeTransfer(AuthorizeTransferRequest) returns (AuthorizeTransferResponse); + // Delegate records a signed-in person's grant to a module (the caller's + // bearer is the person); Issue with a delegation acts on it. + rpc Delegate(DelegateRequest) returns (Delegation); + // RevokeDelegation ends a delegation; the next Issue from it is refused. + rpc RevokeDelegation(RevokeDelegationRequest) returns (RevokeDelegationResponse); + // GetDelegation reads one delegation for the module it is granted to (by its + // workload credential) or for its grantor (by the caller's bearer): whether it + // still stands. Work that outlives the capability that started it asks this + // between issues, so a revoke stops it even when it calls no other module. + rpc GetDelegation(GetDelegationRequest) returns (Delegation); +} + +// IssueRequest names on whose behalf the capability is issued. +message IssueRequest { + // Who the capability is for; exactly one. + oneof as { + // The signed-in person making this call, identified by the bearer the call + // carries; the host opened that session at sign-in. + PersonSession person = 1; + // A module's own workload, proving which module it is. + WorkloadCredential workload = 2; + // An installation acting for itself, with the scope the organisation consented to. + // actor must authenticate a workload of that installation's solution. + InstallationRef installation = 3; + // One more hop from an existing capability: a child, an exchange or a renewal. + string parent_token = 4; + // A delegation a person granted the module acting (actor): the capability + // is owned by that person and carries the delegation on the module's hop. + // No live capability is presented: the authority re-checks the delegation + // and what its grantor holds now, so a revoked delegation or a lost role + // stops the next issue, and renewing is issuing again. + DelegationRef delegation = 11; + } + // The module principal that may consume the capability. + string audience = 5; + // The scopes asked for; empty means everything the owner holds at the audience. + repeated WorkScopeV1 scopes = 6; + // Requested lifetime in seconds; clamped to the issuer's ceiling. + int64 ttl_seconds = 7; + // The declared binding this capability exercises, when the owner is a workload + // or a child acts through one. + string operation_binding_id = 8; + // The organisation the work runs in. A workload may leave this empty when + // exactly one organisation has an active installation of its module. + // Otherwise it must name the organisation of the work item; + // absent or ambiguous installation authority is refused with a reason. + string organization_id = 9; + // The acting workload credential; required with parent_token, delegation, + // and installation. For installation it must name the installed solution. + WorkloadCredential actor = 10; + // Which of the binding's operations the capability is for: "invoke" (the + // default for a person, a child or a delegation), "headless" (the default for + // a workload) or "lookup": the binding's lookup scopes alone, to read a + // receipt without the authority to repeat the effect. + string operation = 12; +} + +// DelegationRef names one delegation. +message DelegationRef { + // The delegation's id, as Delegate answered it. + string delegation_id = 1; +} + +// DelegateRequest is a signed-in person granting a module the right to act for +// them beyond any session: on scopes they hold, through the module's bindings. +message DelegateRequest { + // The module granted, by principal. + string module = 1; + // What the module may do for the person; within what the person holds. + repeated WorkScopeV1 scopes = 2; + // Why, as the person was told (a connected source, a scheduled job). + string purpose = 3; +} + +// Delegation is one grant from a person to a module. +message Delegation { + string delegation_id = 1; + string organization_id = 2; + // The person who granted it, and whom every capability issued from it is for. + string grantor = 3; + // The module granted. + string module = 4; + repeated WorkScopeV1 scopes = 5; + string purpose = 6; + // False once revoked; a revoked delegation issues nothing. + bool active = 7; +} + +// GetDelegationRequest names the delegation read; anyone but its module or its +// grantor is told it does not exist. +message GetDelegationRequest { + string delegation_id = 1; + // The module asking, proving which module it is; absent when the grantor asks. + WorkloadCredential actor = 2; +} + +// RevokeDelegationRequest ends a delegation; only its grantor may. +message RevokeDelegationRequest { + string delegation_id = 1; +} + +// RevokeDelegationResponse is the delegation as it now stands. +message RevokeDelegationResponse { + Delegation delegation = 1; +} + +// PersonSession names the caller's own session. It carries no field: the person +// is whoever the bearer on the call authenticates as, so a caller cannot ask for +// another person's authority by naming a session. +message PersonSession { + reserved 1; + reserved "session_id"; +} + +// WorkloadCredential proves which module a running process is: the native +// launcher credential core's workloadidentity defines, signed by the one launcher +// the host was handed an anchor for. +message WorkloadCredential { + // The launcher-signed credential, as core's native issuer writes it. + string credential = 1; +} + +// InstallationRef names an installation acting for itself. +message InstallationRef { + // The installation id. + string installation_id = 1; +} + +// IssueResponse carries the signed capability and its decoded form. +message IssueResponse { + // The signed capability, as a caller presents it. + string token = 1; + // When the capability expires. + int64 expires_at_unix = 2; + // The decoded capability, for the holder to read its own claims. + WorkContextV1 context = 3; +} + +// CheckRequest presents a capability for verification. +message CheckRequest { + // The capability presented. + string token = 1; + // The audience the caller is; a capability addressed elsewhere is refused. + string audience = 2; + // The method the capability is presented to: "/package.Service/Method" for a + // gRPC call, " " for an HTTP request (GET /v1/model/profiles), + // matched against the routes the module declares. When set, the check also + // applies what the audience module's delivery declares for that method, and + // refuses by name a call that does not hold it, a method that declares + // nothing, and a method the module does not serve. + string method = 3; + // Advisory asks for the policy decision without its refusal: the capability + // is still verified and refused when it is not sound, but what the method + // declares is only reported, in CheckResponse.refusal, and the verified + // capability is answered. A hop in a composition that runs permissive + // (hop/ENFORCEMENT=warn) asks this way and logs the refusal it did not make. + // Hosts may refuse advisory outside local development runs. + bool advisory = 4; +} + +// CheckResponse is the verified capability. +message CheckResponse { + // The verified capability. + WorkContextV1 context = 1; + // The owner's authorization revision as of this check. + uint64 authorization_revision = 2; + // Refusal is what the method's policy would have refused, verbatim, when the + // check was advisory and the policy did not hold; empty when it held. + string refusal = 3; +} + +// KeysRequest asks for the verification keys. +message KeysRequest {} + +// KeysResponse is the issuer and its keys. +message KeysResponse { + // The issuer every capability names. + string issuer = 1; + // The public keys by key id: what a Work Context is signed with, and only + // that. Each carries purpose "work-context". + repeated PublicKey keys = 2; + // The keys a hop signs each call's admission with, each with purpose + // "hop-admission": a module behind the hop verifies the admission by one of + // them. More than one is published while a hop's key is rotated, so the + // retiring and the new key overlap. They are never Work Context keys and are + // kept out of keys, so no verifier that reads keys alone can trust one to + // sign a Work Context. Empty until a hop registers its key + // (RegisterAdmissionKey). + repeated PublicKey admission_keys = 3; +} + +// PublicKey is one verification key. +message PublicKey { + // The key id a capability names. + string key_id = 1; + // The signature algorithm; Ed25519. + string algorithm = 2; + // The raw public key bytes. + bytes public_key = 3; + // What the key verifies: "work-context" (a Work Context) or "hop-admission" + // (a hop's signed call admission). A verifier uses a key only for its own + // purpose. + string purpose = 4; +} + +// RegisterAdmissionKeyRequest presents one hop admission key. +message RegisterAdmissionKeyRequest { + // The hop's workload credential; any other workload is refused. + WorkloadCredential actor = 1; + // The raw Ed25519 public key the hop signs admissions with: 32 bytes. + bytes public_key = 2; +} + +// RegisterAdmissionKeyResponse is the key as Keys now publishes it. +message RegisterAdmissionKeyResponse { + // The published entry, with its key id and purpose "hop-admission". + PublicKey key = 1; + // When the registration lapses unless the hop registers the key again. + int64 expires_at_unix = 2; +} + +// ListResourcesRequest asks about one kind and one action. +message ListResourcesRequest { + // The capability presented. + string token = 1; + // The audience the caller is; a capability addressed elsewhere is refused. + string audience = 2; + // The resource kind, as the audience module serves it. + string resource_kind = 3; + // The action, in the kind's own vocabulary. + string action = 4; +} + +// ListResourcesResponse is every grant the capability holds on the kind for the +// action. A resource is covered when any grant matches its id; no grant means +// none. A capability that does not hold the action at all is refused instead. +message ListResourcesResponse { + repeated ResourceGrant grants = 1; +} + +// AttachResourceRequest names one resource and the capability it was created under. +message AttachResourceRequest { + // The capability the creating call carried, addressed to the caller. + string token = 1; + // The module serving resource_kind, proving which module it is. + WorkloadCredential actor = 2; + // The resource kind, as the caller serves it. + string resource_kind = 3; + // The resource id, as the caller knows it. + string resource_id = 4; +} + +// AttachResourceResponse names the installation that now owns the resource. +message AttachResourceResponse { + // The owning installation, read from the capability's provenance. + string installation_id = 1; +} + +// DetachResourceRequest names one resource its serving module deleted. +message DetachResourceRequest { + // The module serving resource_kind, proving which module it is. + WorkloadCredential actor = 1; + // The organisation the resource belongs to. + string organization_id = 2; + // The resource kind, as the caller serves it. + string resource_kind = 3; + // The resource id, as the caller knows it. + string resource_id = 4; +} + +// DetachResourceResponse names the installation that owned the resource. +message DetachResourceResponse { + // The installation released from it; empty when none owned it. + string installation_id = 1; +} + +// AuthorizeTransferRequest names one existing resource and where it moves. +message AuthorizeTransferRequest { + // The capability of the person asking, addressed to the caller. + string token = 1; + // The module serving resource_kind, proving which module it is. + WorkloadCredential actor = 2; + // The resource kind, as the caller serves it. + string resource_kind = 3; + // The resource id, as the caller knows it. + string resource_id = 4; + // The resource's current owner, as the caller records it: the organization + // or a person of it. + string from_owner_id = 5; + // The solution principal the resource joins. + string to_principal_id = 6; +} + +// AuthorizeTransferResponse is the decision the store records. +message AuthorizeTransferResponse { + // The principal that now owns the resource. + string principal_id = 1; + // The person who authorized the transfer. + string authorized_by = 2; +} + +// ResourceGrant is one way of naming covered resources. Exactly one match is +// set; resource_owner_id, when set, qualifies it. +message ResourceGrant { + oneof match { + // Every resource of the kind. + bool all = 1; + // Exactly this resource id. + string id = 2; + // Every resource whose id starts with this. + string prefix = 3; + // Every resource whose whole id matches this RE2 expression, at most 256 + // bytes; it is matched against the entire id, as if anchored. + string pattern = 4; + } + // The owner the covered resources must have: a resource is covered when + // the match covers its id and it is owned by this principal. + string resource_owner_id = 5; +} diff --git a/modules/authority/contracts/api/local/grpc/proto/host/authority/v1/work_context.proto b/modules/authority/contracts/api/local/grpc/proto/host/authority/v1/work_context.proto new file mode 100644 index 00000000..da5c5d69 --- /dev/null +++ b/modules/authority/contracts/api/local/grpc/proto/host/authority/v1/work_context.proto @@ -0,0 +1,308 @@ +syntax = "proto3"; +// Moved from codefly-dev/core proto/codefly/base/v0/work_context.proto on 2026-10-21. +// Every message, field name and field number is unchanged, so the deterministic +// bytes a verifier reads are identical; only the package and the Go import path +// differ. The conformance fixtures prove it. A change in meaning is a V2 message +// beside the V1, never a field reinterpreted. +package host.authority.v1; + +option go_package = "gateway/pkg/gen/host/authority/v1;authorityv1"; + +import "buf/validate/validate.proto"; + +// WorkContextV1 is the product-neutral identity and authority capability for +// one Task/Session hop. The signed wire representation is owned by Codefly +// SDKs; applications must never serialize this message into a bearer token or +// construct the transport header themselves. +// +// Task identity is the tuple (tenant_id, owner_principal_id, task_id). +// Starting a child agent keeps that tuple and exchanges the parent capability +// for a new child session capability. It never mutates an existing capability. +message WorkContextV1 { + // typ is fixed to "codefly.work-context/v1". + string typ = 1 [(buf.validate.field).string.const = "codefly.work-context/v1"]; + // algorithm is fixed to "Ed25519". + string algorithm = 2 [(buf.validate.field).string.const = "Ed25519"]; + // key_id selects one public verification key during rotation. + string key_id = 3 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 128]; + // issuer is the authority that verified identity and minted this context. + string issuer = 4 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // audience is the exact service or trust boundary allowed to consume it. + string audience = 5 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // not_before_unix is the earliest instant a verifier may accept this + // capability. + int64 not_before_unix = 6; + // issued_at_unix is when the issuer minted it. + int64 issued_at_unix = 7; + // expires_at_unix is the instant it stops being accepted. Exchanging a + // parent capability for a child one never extends it. + int64 expires_at_unix = 8; + // nonce identifies the capability. Replay behavior is separately explicit. + string nonce = 9 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // authorization_revision is the issuer's current membership/policy revision. + uint64 authorization_revision = 10; + // replay_policy is "idempotent" or "single-use". A verifier still needs a + // durable replay store to enforce single-use consumption. + string replay_policy = 11 [(buf.validate.field).string.in = "idempotent", (buf.validate.field).string.in = "single-use"]; + + // tenant_id is the first element of the task identity tuple and is carried + // unchanged across every hop. + string tenant_id = 12 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // owner_principal_id is the principal on whose authority the task runs. It + // is the delegating owner, not necessarily the current actor. + string owner_principal_id = 13 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // task_id completes the task identity tuple and survives child agents. + string task_id = 14 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // session_id identifies this hop. Starting a child agent mints a new one + // rather than mutating this capability. + string session_id = 15 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // parent_session_id is the session this one was exchanged from; absent on + // the first session of a task. + optional string parent_session_id = 16 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + + // authority_scopes are the maximum scopes the owner delegated into this + // Work Context. Each actor hop's granted_scopes must be a subset of the + // preceding effective scopes. + repeated WorkScopeV1 authority_scopes = 17; + // actor_chain is ordered from the first delegated actor to the current actor. + // The owner is not duplicated here merely to make a direct call look uniform. + // Every hop attenuates the one before it, except the final hop of a + // capability carrying grant_hop. + repeated WorkActorV1 actor_chain = 18; + // attribution_team_ids is an immutable verified attribution snapshot. It + // explains ownership; current authorization is still evaluated separately. + repeated string attribution_team_ids = 19; + // workspace_id is the workspace the task runs against, when the task is + // scoped to one. + optional string workspace_id = 20 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // project_id narrows workspace_id to a single project, when the task is + // scoped that far. + optional string project_id = 21 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + + // owner_principal_kind distinguishes the sort of principal the owner is, so + // a verifier can present the owner without resolving it against a directory. + // Optional on the wire: a capability minted before this field existed is + // still a valid capability, and an archived receipt embedding one still + // verifies. Deriving an identity from a capability that omits it is the + // error, raised where the identity is built. + optional string owner_principal_kind = 22 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 128]; + // owner_agent_id is the owner's agent manifest identity. Required when + // owner_principal_kind is "agent" and absent otherwise. + optional string owner_agent_id = 23 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // organization_id is the organization the task runs in. It is not the + // tenant: a tenant may hold several organizations, and authorization is + // scoped per organization. An actor hop may name a different one. + optional string organization_id = 25 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // grant_hop is the approval grant this capability was minted under. It is + // the one audited exception to the attenuation rule: the final actor hop + // holds exactly granted_scope, which the preceding hop need not contain. + // Present only on a single-use child capability minted for one approved + // call; absent on every ordinary session. + optional WorkGrantHopV1 grant_hop = 24; + // seal binds this capability to one installation and one execution: the + // principal's epoch, the installation and revision it was minted for, and + // the build incarnation it was minted on. A verifier compares every field + // against the live value and refuses on any mismatch, which is what stops a + // capability outliving the installation or the build it was issued against. + // + // REQUIRED. An earlier version of this field was optional, with the stated + // reason that a schema rule would invalidate every archived capability and + // every receipt embedding one, so the requirement belonged in the verifier. + // Two independent reviews named that for what it is: a backward-compatibility + // hedge, which the rules in force forbid. It also meant every reader other + // than the verifier saw a capability's binding to its installation as + // optional, so the strongest check in the model was the easiest one for a + // consumer to not notice. + // + // Archived capabilities and receipts are historical DATA. If they must be + // read after this, they get a snapshot type of their own rather than keeping + // the live credential permanently weaker than it should be. + WorkSealV1 seal = 26 [(buf.validate.field).required = true]; + // operation_binding is the unit of authority this capability exercises, + // sealed by its id, revision and incarnation. Present on an operation + // context and absent on a session that exercises none. + // + // The id is what a verifier looks the binding up by, exactly. Nothing + // searches the bindings for one that contains the capability's scopes: a + // search is a predicate someone wrote, and a predicate one case too + // generous grants authority nobody reviewed. + optional WorkOperationBindingV1 operation_binding = 27; +} + +// WorkSealV1 binds a capability to one installation and one execution. Its +// principal_epoch is the TASK OWNER'S; an actor hop carries its own in +// WorkActorV1.principal_epoch, because an actor's authority is narrowed +// independently of the owner's. Every +// field is a value the issuer holds live and the verifier compares exactly, so +// a capability minted before an installation was revised, a principal's +// authority was reset, or a build was replaced stops verifying at that moment +// rather than at its own expiry. +message WorkSealV1 { + // principal_epoch is the principal's current epoch. Advancing it + // invalidates every capability minted for that principal, which is how a + // compromised principal is cut off without waiting for expiry. + uint64 principal_epoch = 1 [(buf.validate.field).uint64.gte = 1]; + // installation_id identifies one organization's installation of one module. + // Authority is held through an installation, so a capability that names none + // is bound to nothing. + string installation_id = 2 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // installation_revision is the installation's revision at mint time. A + // verifier holding a different revision refuses: the scope of an + // installation changes with its revision, so a capability sealed to an + // earlier one asks for authority under terms that no longer apply. + uint64 installation_revision = 3 [(buf.validate.field).uint64.gte = 1]; + // Fields 4 and 5 carried a per-capability build seal (build_incarnation, + // image_digest). Removed: a workload's build is checked where it mints, and + // a change of approved builds revokes through the principal's epoch and the + // installation's revision. + reserved 4, 5; + reserved "build_incarnation", "image_digest"; +} + +// WorkOperationBindingV1 is the unit of authority an operation context +// exercises, sealed so that a verifier resolves exactly one binding and +// compares it rather than deciding which binding the capability's scopes fit. +message WorkOperationBindingV1 { + // binding_id is the host's identifier for the binding. The verifier looks it + // up EXACTLY: nothing searches the bindings for one that fits a capability's + // scopes, because a search is a predicate somebody wrote and a predicate one + // case too generous grants authority nobody reviewed. + // + // This comment used to say the id "is never derived from the binding's + // contents, because a derived id is one a caller can compute for a binding + // it was never granted". I refuted a review finding against that sentence by + // reading it in isolation and judging it correct. The reviewer was right and + // I was wrong: solutionhost/authority.go says the opposite in the package + // that owns the id — deriving it deterministically is delivery's business + // and better than random, and "predictability costs nothing here: an id is + // neither a secret nor a capability". + // + // Predictability costs nothing because ENTITLEMENT is what refuses a binding + // a caller does not hold, not obscurity: the verifier requires the resolved + // binding to be granted to the exercising principal within the installation + // the capability is sealed to. Guessing an id gets you a refusal. Resting + // the rule on unguessability was the retracted reasoning, and leaving it + // here contradicted the package it describes. + string binding_id = 1 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // revision is the binding's revision at mint time. A verifier holding a + // different revision refuses. + uint64 revision = 2 [(buf.validate.field).uint64.gte = 1]; + // incarnation is the binding's incarnation at mint time, which changes when + // the binding is re-established rather than merely revised. + uint64 incarnation = 3 [(buf.validate.field).uint64.gte = 1]; +} + +// WorkScopeV1 is a structured, product-neutral capability scope. Empty +// resource_ids means every resource of resource_kind; a child may narrow that +// wildcard to explicit IDs but may never widen an explicit parent set. +message WorkScopeV1 { + // resource_kind is the class of resource the scope grants against. + string resource_kind = 1 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 128]; + // actions are the operations permitted on that kind. + repeated string actions = 2; + // resource_ids narrows the scope to specific resources. Empty means every + // resource of resource_kind. + repeated string resource_ids = 3; + // resource_owner_id narrows the scope to the resources this principal owns + // (an installed solution's principal). Empty means the scope selects no + // owner. With resource_ids, a resource is covered only when both match. A + // reader that cannot compare a resource's owner must not treat an + // owner-selected scope as covering the whole kind. It is at most 512 bytes + // of printable ASCII other than space and "*" (0x21-0x29, 0x2B-0x7E): no + // whitespace, control, non-ASCII, invisible or lookalike character, so two + // owners that print alike are the same bytes and every regex engine answers + // the rule the same way. + string resource_owner_id = 4 [(buf.validate.field).string.max_bytes = 512, (buf.validate.field).string.pattern = "^[!-)+-~]*$"]; +} + +// WorkActorV1 is one verified delegated actor. granted_scopes are the actor's +// effective scopes and must monotonically attenuate the previous hop. +message WorkActorV1 { + // principal_id identifies the actor this hop delegated to. + string principal_id = 1 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // principal_kind distinguishes the sort of principal, so a human and an + // agent bearing the same id are not conflated. + string principal_kind = 2 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 128]; + // delegation_id identifies the delegation that produced this hop, so the + // chain stays auditable back to its grant. + string delegation_id = 3 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // granted_scopes are this actor's effective scopes, a subset of the + // preceding hop's. + repeated WorkScopeV1 granted_scopes = 4; + // agent_id is the actor's agent manifest identity. Required when + // principal_kind is "agent" and absent otherwise. + optional string agent_id = 5 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // organization_id is the organization this actor belongs to, when it is not + // the one the task runs in. An org-bridge agent acts in an organization the + // owner does not belong to, and authorization is scoped to the actor's own. + optional string organization_id = 6 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // principal_epoch is THIS actor's epoch, distinct from the owner's epoch in + // the seal. Advancing it invalidates every capability in which this + // principal is an actor, without touching the owner's. + // + // It exists because the seal's epoch is the task owner's, and an actor's + // authority can be narrowed independently of the owner's: a delegated + // operation context may have a person as its owner and a service principal + // as its actor, and narrowing that service principal must reach the + // capabilities it acts in. Without this field the only lever for a + // compromised actor is the tenant's authorization_revision, which cuts off + // every capability of the tenant. + // + // REQUIRED, for the reason seal gives. A hop with no epoch is a principal + // nothing can revoke, and leaving that expressible on the wire made it a + // shape a consumer could mint without noticing. + uint64 principal_epoch = 7 [(buf.validate.field).uint64.gte = 1, (buf.validate.field).required = true]; + // Fields 8 and 9 carried this hop's build seal; removed with the seal's. + reserved 8, 9; + reserved "image_digest", "build_incarnation"; +} + +// WorkGrantHopV1 records the approval that justifies a capability holding +// authority its delegating owner never held. It is minted only as a new child +// capability bound to one tool, one subject and one call, so the authority it +// adds is unusable anywhere else and expires with the grant window. +message WorkGrantHopV1 { + // grant_id identifies the approval grant, so revoking the grant and + // auditing the call both reach the same record. + string grant_id = 1 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // approvers are the principals whose decision produced the grant, every one + // of an N-of-M quorum, so the audit record is complete. + repeated WorkApproverV1 approvers = 2 [(buf.validate.field).repeated.min_items = 1, (buf.validate.field).repeated.max_items = 64]; + // granted_scope is exactly what the approval added: one resource_kind, one + // action and one resource_id. A grant never widens beyond that triple. + WorkScopeV1 granted_scope = 3 [(buf.validate.field).required = true]; + // subject is what the grant is pinned to, in the approvals engine's own + // vocabulary, so a grant approved for one subject cannot be spent on another. + string subject = 4 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // request_digest binds the grant to the exact call the approver saw. + string request_digest = 5 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; +} + +// WorkApproverV1 is one principal whose decision produced a grant. It carries +// the kind as well as the id: a policy that requires a human approver cannot +// be written against bare identifiers. +message WorkApproverV1 { + // principal_id identifies the approver. + string principal_id = 1 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // principal_kind distinguishes the sort of principal the approver is. + string principal_kind = 2 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 128]; +} + +// ApprovalRequiredV1 is the signal a policy decision point returns instead of +// a denial when the call is approvable: the authority is not held, but an +// approver may grant it. It names exactly what a WorkGrantHopV1 would have to +// carry, so a decision and the capability that satisfies it are the same shape. +// It is carried as a typed error detail on a FailedPrecondition status. +message ApprovalRequiredV1 { + // request_id identifies the approval request the engine created, which the + // caller waits on and the resulting grant refers back to. + string request_id = 1 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // requested_scope is the authority the call needs and the principal lacks. + WorkScopeV1 requested_scope = 2 [(buf.validate.field).required = true]; + // subject is what a grant would be pinned to. + string subject = 3 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // request_digest is the exact call put to the approver. + string request_digest = 4 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; + // audience is the tool a granted capability must be minted for. + string audience = 5 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512]; +} diff --git a/modules/authority/contracts/api/local/grpc/proto/host/composition/v1/composition.proto b/modules/authority/contracts/api/local/grpc/proto/host/composition/v1/composition.proto new file mode 100644 index 00000000..a834fd4c --- /dev/null +++ b/modules/authority/contracts/api/local/grpc/proto/host/composition/v1/composition.proto @@ -0,0 +1,171 @@ +syntax = "proto3"; +// The composition interface: how the host learns what is composed. One +// operation. The render produces a manifest per module instance; the host +// derives from it the module's principal, its ceilings and its bindings. There +// is no envelope: the ceiling is the manifest. +package host.composition.v1; + +option go_package = "gateway/pkg/gen/host/composition/v1;compositionv1"; + +import "host/authority/v1/work_context.proto"; + +// CompositionService is implemented by the host and by the local authority. +service CompositionService { + // ApplyManifest delivers one module instance's manifest at one generation. + rpc ApplyManifest(ApplyManifestRequest) returns (ApplyManifestResponse); +} + +// ApplyManifestRequest carries the manifest. +message ApplyManifestRequest { + // The sketch of a manifest, kept through the transition. An implementation + // that verifies deliveries ignores it when a carrier is present. + ModuleManifestV1 manifest = 1; + // The delivery as Codefly signs it: core's module manifest and the DSSE + // envelope over it. An implementation verifies the envelope against the release + // keys it pins and reads the manifest from the verified document, never from + // the request, so what composes a module is the same signed document on the + // local authority and on the host. + SignedManifestV1 carrier = 2; + // The credential of the workload delivering the manifest, as that + // implementation attests workloads: the launcher's on the local authority, the + // delivery service account's on the host. A manifest is delivered by its own + // module's delivery workload and by nothing else. + string credential = 3; + // The authority's own document for this delivery, an AuthorityManifestV1: + // what the module declares that the authority enforces and core's manifest + // does not carry. Signed in the same envelope format by the same key as + // carrier, and naming carrier's digest. Absent means the module declares no + // method policy, and every Check that names one of its methods is refused. + SignedManifestV1 authority = 4; +} + +// AuthorityManifestV1 is the authority's document for one delivery. +message AuthorityManifestV1 { + // The module, as the core manifest it travels with names it. + string module = 1; + // The generation, equal to that core manifest's. + uint64 generation = 2; + // "sha256:" of the core manifest document delivered with it. + string core_manifest_digest = 3; + // Every method the module's published contracts serve, with what each + // declares. A method that declares nothing is listed with neither field set. + repeated MethodPolicyV1 methods = 4; + // The module's own resource kinds with the actions its contract declares on + // each (its scope ceilings). Core's manifest names the kinds alone, so without + // these a kind serves any action and a person's ask for everything they hold + // there cannot be enumerated. Each names a kind core's manifest declares. + repeated host.authority.v1.WorkScopeV1 scope_ceilings = 5; + // The roles a solution declares a person may hold on an installation of it, + // as ModuleManifestV1.roles declares them through the sketch: each within + // the solution's own scope ceilings or explicit invoke/lookup binding scopes, + // one of them installer. Headless-only scopes cannot supply a role grant. + // Core's manifest carries no roles, so they travel here until it does. A + // module declares none; a module's delivery listing any is refused. + repeated RoleV1 roles = 6; + // The module checks a resource's owner against an owner-selected scope + // (Claims.AllowsOwned): only an audience declaring it receives one, and a + // delivery withdrawing it refuses the owner-selected capabilities issued + // before. Every solution receiving its roles' scopes declares it. + bool owner_aware = 7; +} + +// MethodPolicyV1 is what a call to one method must hold, as the module's +// contract declares it with the authority.hop.v1.policy option. +message MethodPolicyV1 { + // The method: "/package.Service/Method" for a gRPC method, or an HTTP route + // "