From 53cc6c20acd52dc0c6a33e2418e63a83367409bd Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Fri, 7 Aug 2026 18:51:12 +0800 Subject: [PATCH] Define controlled-session authorization Add immutable session authorization records that bind opaque handles to exact deployments, generations, builds, admitted runs, container plans, runtime identities, controller operations, and declared endpoints. Extract the canonical container runtime identity contract so blueprint resolution, application sandbox planning, and controlled sessions share the same portable user, numeric ID, root-group, and supplementary-group validation. Reuse the deployment live-run ID validator instead of defining another controlled-session grammar. Keep connection ownership and lifetime as non-transferable host lease state: one controller connection owns one admitted live run until disconnect or host cancellation. Document that boundary and prevent authorization digests from implying reconnect or lease extension. Derive canonical authorization digests and add deterministic handle, shared identity, and adversarial validation coverage. --- .../+workload-endpoint-identifiers.yaml | 2 + docs/BLUEPRINT_ENVIRONMENT_MODEL.md | 9 +- docs/CONTROLLED_SESSION_DESIGN.md | 27 ++- internal/blueprint/extends.go | 28 ++- internal/blueprint/resolve.go | 20 +-- internal/blueprint/resolve_test.go | 44 +++++ internal/controlledsession/authorization.go | 160 ++++++++++++++++++ .../controlledsession/authorization_test.go | 104 ++++++++++++ .../dockerdeploy/application_sandbox_plan.go | 25 ++- internal/endpointname/name.go | 23 +++ internal/endpointname/name_test.go | 47 +++++ internal/runtimeidentity/identity.go | 94 ++++++++++ internal/runtimeidentity/identity_test.go | 52 ++++++ 13 files changed, 601 insertions(+), 34 deletions(-) create mode 100644 .changes/unreleased/+workload-endpoint-identifiers.yaml create mode 100644 internal/controlledsession/authorization.go create mode 100644 internal/controlledsession/authorization_test.go create mode 100644 internal/endpointname/name.go create mode 100644 internal/endpointname/name_test.go create mode 100644 internal/runtimeidentity/identity.go create mode 100644 internal/runtimeidentity/identity_test.go diff --git a/.changes/unreleased/+workload-endpoint-identifiers.yaml b/.changes/unreleased/+workload-endpoint-identifiers.yaml new file mode 100644 index 00000000..80055a26 --- /dev/null +++ b/.changes/unreleased/+workload-endpoint-identifiers.yaml @@ -0,0 +1,2 @@ +kind: Changed +body: Require workload endpoint names to use one shared Docker-style component grammar across blueprint resolution and controlled-session authorization. diff --git a/docs/BLUEPRINT_ENVIRONMENT_MODEL.md b/docs/BLUEPRINT_ENVIRONMENT_MODEL.md index 19f2308b..6d98353a 100644 --- a/docs/BLUEPRINT_ENVIRONMENT_MODEL.md +++ b/docs/BLUEPRINT_ENVIRONMENT_MODEL.md @@ -1,6 +1,6 @@ --- status: Active -updated: 2026-08-02 +updated: 2026-08-08 summary: Normative blueprint environment, workload, application, provider contribution, lifecycle, and Docker rendering model. supersedes: docs/CROSS_PLATFORM_INSTALL_LOCATIONS.md --- @@ -2094,6 +2094,13 @@ could use a structured configuration system. cycles are not possible with the initial environment-to-backend-only rule, implementations should still reject them rather than recurse. +Workload endpoint names use one Docker Distribution image-name path component: +lowercase alphanumeric segments separated by `.`, `_`, `__`, or one or more +`-`, with a maximum length of 128 bytes. Names such as `api_v1`, `api.v1`, +`api--v1`, and `2fa` are valid. Full image-reference syntax such as `/`, `:`, +and `@` is not accepted. Reploy applies this same grammar when endpoint names +become controlled-session capability identifiers. + `environment.workload.endpoints..port` is the authoritative port on which the workload listens inside the container. `extends` copies that port into the Docker endpoint. Docker adds the internal bind address and scope-specific host diff --git a/docs/CONTROLLED_SESSION_DESIGN.md b/docs/CONTROLLED_SESSION_DESIGN.md index 1288d754..d9c0a4d9 100644 --- a/docs/CONTROLLED_SESSION_DESIGN.md +++ b/docs/CONTROLLED_SESSION_DESIGN.md @@ -1,6 +1,6 @@ --- status: Active -updated: 2026-08-02 +updated: 2026-08-08 summary: Capability-scoped execution sessions that inherit Reploy's global container sandbox. --- @@ -9,10 +9,10 @@ summary: Capability-scoped execution sessions that inherit Reploy's global conta ## Status - Decision state: Focused review complete; high-level decisions approved -- Implementation state: Initial global sandbox prerequisites and trusted - application-startup verification implemented in the current slice; - controlled-session authorization, protocol, lifecycle, and Docker - orchestration remain later slices +- Implementation state: Initial global sandbox prerequisites, trusted + application-startup verification, and controlled-session authorization are + implemented; protocol, lifecycle, and Docker orchestration remain later + slices - Initial runtime: Linux containers under Docker - Motivating clients: OmegaFlow recording, sandboxed AI agents, security inspection, and untrusted-code execution @@ -383,7 +383,15 @@ record containing: - the network and endpoint grants; - the mount and source grants; - the permitted session and endpoint operations; -- the lease lifetime and owner connection. +- the admitted lease identity and its owner-connection policy. + +The authorization record is portable immutable data; it does not serialize a +live connection or make ownership transferable. Host Reploy binds the record +to its admitted live-run lease, permits exactly one controller connection to +claim that lease, and treats that connection as the owner until it closes or +Host Reploy cancels the session. Connection loss ends the lease. The initial +protocol has no reconnect, ownership transfer, or operation that can extend the +lease by presenting an authorization digest again. The controller does not receive a generic session-creation capability. After creation, protocol operations do not accept a deployment name, mount, identity, @@ -392,6 +400,13 @@ They act only on the session and logical endpoint identities established by the host-created plan. A generation change invalidates admission of a pending session; it does not retarget a live session. +Logical endpoint identities are the exact names declared by the resolved +blueprint. Blueprint resolution and authorization validation share one +Docker-style single path-component grammar: lowercase alphanumeric segments +separated by `.`, `_`, `__`, or one or more `-`, with a 128-byte maximum. Full +image-reference syntax is not accepted. This keeps the immutable capability +record aligned with every blueprint that can reach runtime planning. + A unique private endpoint and opaque handle prevent accidental cross-session use, but secrecy is not the sole security boundary. Isolation relies on: diff --git a/internal/blueprint/extends.go b/internal/blueprint/extends.go index 2130ceb8..8ef65cb7 100644 --- a/internal/blueprint/extends.go +++ b/internal/blueprint/extends.go @@ -4,6 +4,8 @@ import ( "fmt" "sort" "strings" + + "github.com/omry/reploy/internal/endpointname" ) const environmentMountReferencePrefix = "environment.mounts." @@ -53,7 +55,7 @@ func resolveExtends(source Syntax) (extendedSyntax, error) { endpointNames := sortedKeys(source.Docker.Workload.Endpoints) for _, name := range endpointNames { endpoint := source.Docker.Workload.Endpoints[name] - reference, err := referencedName("docker.workload.endpoints."+name+".extends", endpoint.Extends, environmentEndpointReferencePrefix) + reference, err := referencedEndpointName("docker.workload.endpoints."+name+".extends", endpoint.Extends) if err != nil { return extendedSyntax{}, err } @@ -67,6 +69,28 @@ func resolveExtends(source Syntax) (extendedSyntax, error) { } func referencedName(field string, reference string, prefix string) (string, error) { + name, err := referencedSuffix(field, reference, prefix) + if err != nil { + return "", err + } + if strings.Contains(name, ".") { + return "", fmt.Errorf("%s must reference one named object", field) + } + return name, nil +} + +func referencedEndpointName(field string, reference string) (string, error) { + name, err := referencedSuffix(field, reference, environmentEndpointReferencePrefix) + if err != nil { + return "", err + } + if err := endpointname.Validate(name); err != nil { + return "", fmt.Errorf("%s endpoint name %q: %w", field, name, err) + } + return name, nil +} + +func referencedSuffix(field string, reference string, prefix string) (string, error) { reference = strings.TrimSpace(reference) if reference == "" { return "", fmt.Errorf("%s is required", field) @@ -75,7 +99,7 @@ func referencedName(field string, reference string, prefix string) (string, erro return "", fmt.Errorf("%s must reference %s", field, prefix) } name := strings.TrimPrefix(reference, prefix) - if name == "" || strings.Contains(name, ".") { + if name == "" { return "", fmt.Errorf("%s must reference one named object", field) } return name, nil diff --git a/internal/blueprint/resolve.go b/internal/blueprint/resolve.go index 8f385d1f..675f7fa5 100644 --- a/internal/blueprint/resolve.go +++ b/internal/blueprint/resolve.go @@ -6,6 +6,9 @@ import ( "sort" "strings" "time" + + "github.com/omry/reploy/internal/endpointname" + "github.com/omry/reploy/internal/runtimeidentity" ) var builtInControlOperations = map[string]bool{ @@ -148,17 +151,7 @@ func resolveRuntimeUser(value string) (string, error) { } func ValidateRuntimeUserName(value string) error { - if value == "" || len(value) > 32 { - return fmt.Errorf("must be a nonempty portable Unix user name no longer than 32 bytes") - } - for index, character := range value { - if character >= 'a' && character <= 'z' || character == '_' && index == 0 || - index > 0 && (character >= '0' && character <= '9' || character == '_' || character == '-') { - continue - } - return fmt.Errorf("must be a portable lowercase Unix user name") - } - return nil + return runtimeidentity.ValidateUserName(value) } func resolveConcurrentRunPolicy(value string) (ConcurrentRunPolicy, error) { @@ -615,6 +608,9 @@ func resolveWorkloads(source Syntax, extended extendedSyntax, document *Document _ = command workload := Workload{Command: source.Environment.Workload.Command, Endpoints: map[string]Endpoint{}} for _, name := range sortedKeys(source.Environment.Workload.Endpoints) { + if err := endpointname.Validate(name); err != nil { + return fmt.Errorf("environment.workload.endpoints key %q: %w", name, err) + } endpoint, err := resolveEndpoint("environment.workload.endpoints."+name, source.Environment.Workload.Endpoints[name]) if err != nil { return err @@ -631,7 +627,7 @@ func resolveWorkloads(source Syntax, extended extendedSyntax, document *Document endpointReferences := map[string]int{} for _, name := range sortedKeys(extended.Endpoints) { item := extended.Endpoints[name] - endpointName, _ := referencedName("extends", item.Docker.Extends, environmentEndpointReferencePrefix) + endpointName, _ := referencedEndpointName("extends", item.Docker.Extends) endpointReferences[endpointName]++ resolvedEndpoint := workload.Endpoints[endpointName] stagingPort, err := resolveSyntaxInt(item.Docker.Publish.Staging, "docker.workload.endpoints."+name+".publish.staging") diff --git a/internal/blueprint/resolve_test.go b/internal/blueprint/resolve_test.go index 475bd9c5..9922cc63 100644 --- a/internal/blueprint/resolve_test.go +++ b/internal/blueprint/resolve_test.go @@ -54,6 +54,50 @@ func TestResolveProducesTypedEnvironment(t *testing.T) { } } +func TestResolveValidatesWorkloadEndpointNames(t *testing.T) { + for _, test := range []struct { + name string + ok bool + }{ + {name: "api", ok: true}, + {name: "api_v1", ok: true}, + {name: "api.v1", ok: true}, + {name: "api--v1", ok: true}, + {name: "api__internal", ok: true}, + {name: "2fa", ok: true}, + {name: "API"}, + {name: "-api"}, + {name: "api-"}, + {name: "api/v1"}, + {name: "api:v1"}, + {name: "api@sha256"}, + } { + t.Run(test.name, func(t *testing.T) { + source, err := Decode([]byte(minimalBlueprint)) + if err != nil { + t.Fatal(err) + } + endpoint := source.Environment.Workload.Endpoints["http"] + delete(source.Environment.Workload.Endpoints, "http") + source.Environment.Workload.Endpoints[test.name] = endpoint + dockerEndpoint := source.Docker.Workload.Endpoints["http"] + dockerEndpoint.Extends = "environment.workload.endpoints." + test.name + source.Docker.Workload.Endpoints["http"] = dockerEndpoint + + _, err = Resolve(source) + if test.ok { + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), "endpoint name") { + t.Fatalf("Resolve() error = %v, want endpoint-name diagnostic", err) + } + }) + } +} + func TestResolveRuntimeNetwork(t *testing.T) { for _, test := range []struct { name string diff --git a/internal/controlledsession/authorization.go b/internal/controlledsession/authorization.go new file mode 100644 index 00000000..2eaa4761 --- /dev/null +++ b/internal/controlledsession/authorization.go @@ -0,0 +1,160 @@ +// Package controlledsession defines the host-owned authorization, wire +// protocol, and lifecycle state machine for one controlled session. +// +// The package is intentionally independent of Docker orchestration. Callers +// must construct and validate a complete immutable authorization before they +// create runtime resources or expose a session channel. +package controlledsession + +import ( + "crypto/rand" + "fmt" + "io" + "regexp" + "slices" + "strings" + "unicode" + "unicode/utf8" + + "github.com/omry/reploy/internal/canonical" + "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/endpointname" + "github.com/omry/reploy/internal/runtimeidentity" +) + +const AuthorizationSchemaV1 = "controlled-session-authorization-v1" + +type OperationV1 string + +const ( + OperationInputV1 OperationV1 = "input" + OperationResizeV1 OperationV1 = "resize" + OperationTerminateV1 OperationV1 = "terminate" + OperationCompleteV1 OperationV1 = "complete" + OperationOpenEndpointV1 OperationV1 = "open-endpoint" +) + +// RuntimeIdentityV1 records the exact container-local identity selected before +// the session starts. +type RuntimeIdentityV1 = runtimeidentity.IdentityV1 + +// AuthorizationV1 binds one opaque session handle to one already admitted, +// immutable runtime plan. The plan digests cover all details that are not +// repeated here, including mounts, environment, network, and commands. +// +// Ownership and lifetime are deliberately host runtime state rather than +// transferable fields in this record. The host binds the validated value to +// its LiveRunID, permits exactly one controller connection to claim that lease, +// and ends the lease when that connection is lost or the host cancels it. +type AuthorizationV1 struct { + Schema string `json:"schema"` + Handle string `json:"handle"` + DeploymentID string `json:"deployment_id"` + GenerationReference string `json:"generation_reference"` + BuildIdentity canonical.Digest `json:"build_identity"` + LiveRunID string `json:"live_run_id"` + WorkloadPlan canonical.Digest `json:"workload_plan"` + ControllerPlan canonical.Digest `json:"controller_plan"` + RuntimeIdentity RuntimeIdentityV1 `json:"runtime_identity"` + Operations []OperationV1 `json:"operations"` + EndpointIDs []string `json:"endpoint_ids"` +} + +var sessionHandlePatternV1 = regexp.MustCompile(`^session-[0-9a-f]{64}$`) + +func NewHandleV1() (string, error) { + return newHandleV1(rand.Reader) +} + +func newHandleV1(random io.Reader) (string, error) { + if random == nil { + return "", fmt.Errorf("create controlled-session handle requires randomness") + } + var value [32]byte + if _, err := io.ReadFull(random, value[:]); err != nil { + return "", fmt.Errorf("create controlled-session handle: %w", err) + } + return fmt.Sprintf("session-%x", value), nil +} + +func AuthorizationDigestV1(authorization AuthorizationV1) (canonical.Digest, error) { + if err := ValidateAuthorizationV1(authorization); err != nil { + return "", err + } + return canonical.Sum("controlled-session-authorization", AuthorizationSchemaV1, authorization) +} + +func ValidateAuthorizationV1(authorization AuthorizationV1) error { + if authorization.Schema != AuthorizationSchemaV1 { + return fmt.Errorf("controlled-session authorization schema must be %q", AuthorizationSchemaV1) + } + if !sessionHandlePatternV1.MatchString(authorization.Handle) { + return fmt.Errorf("controlled-session handle must use session- followed by 64 lowercase hexadecimal characters") + } + if err := validateSafeTextV1("deployment ID", authorization.DeploymentID); err != nil { + return err + } + if err := validateSafeTextV1("generation reference", authorization.GenerationReference); err != nil { + return err + } + if err := authorization.BuildIdentity.Validate(); err != nil { + return fmt.Errorf("controlled-session build identity: %w", err) + } + if err := deploy.ValidateLiveRunIDV1(authorization.LiveRunID); err != nil { + return fmt.Errorf("controlled-session live-run ID: %w", err) + } + if err := authorization.WorkloadPlan.Validate(); err != nil { + return fmt.Errorf("controlled-session workload plan: %w", err) + } + if err := authorization.ControllerPlan.Validate(); err != nil { + return fmt.Errorf("controlled-session controller plan: %w", err) + } + if err := runtimeidentity.ValidateIdentityV1(authorization.RuntimeIdentity); err != nil { + return fmt.Errorf("controlled-session runtime identity: %w", err) + } + if authorization.Operations == nil || authorization.EndpointIDs == nil { + return fmt.Errorf("controlled-session authorization collections must use arrays") + } + for index, operation := range authorization.Operations { + switch operation { + case OperationInputV1, OperationResizeV1, OperationTerminateV1, OperationCompleteV1, OperationOpenEndpointV1: + default: + return fmt.Errorf("controlled-session operation %q is unsupported", operation) + } + if index > 0 && authorization.Operations[index-1] >= operation { + return fmt.Errorf("controlled-session operations must be unique and sorted") + } + } + for index, endpointID := range authorization.EndpointIDs { + if err := endpointname.Validate(endpointID); err != nil { + return fmt.Errorf("controlled-session endpoint ID %q: %w", endpointID, err) + } + if index > 0 && authorization.EndpointIDs[index-1] >= endpointID { + return fmt.Errorf("controlled-session endpoint IDs must be unique and sorted") + } + } + if len(authorization.EndpointIDs) != 0 && !slices.Contains(authorization.Operations, OperationOpenEndpointV1) { + return fmt.Errorf("controlled-session endpoint grants require the open-endpoint operation") + } + return nil +} + +func cloneAuthorizationV1(authorization AuthorizationV1) AuthorizationV1 { + result := authorization + result.RuntimeIdentity.SupplementaryGIDs = append([]string{}, authorization.RuntimeIdentity.SupplementaryGIDs...) + result.Operations = append([]OperationV1{}, authorization.Operations...) + result.EndpointIDs = append([]string{}, authorization.EndpointIDs...) + return result +} + +func validateSafeTextV1(field string, value string) error { + if value == "" || len(value) > 512 || !utf8.ValidString(value) || strings.TrimSpace(value) != value { + return fmt.Errorf("controlled-session %s must be nonempty safe text", field) + } + for _, character := range value { + if unicode.IsControl(character) || unicode.In(character, unicode.Cf) { + return fmt.Errorf("controlled-session %s must be nonempty safe text", field) + } + } + return nil +} diff --git a/internal/controlledsession/authorization_test.go b/internal/controlledsession/authorization_test.go new file mode 100644 index 00000000..3873b1a7 --- /dev/null +++ b/internal/controlledsession/authorization_test.go @@ -0,0 +1,104 @@ +package controlledsession + +import ( + "bytes" + "strings" + "testing" + + "github.com/omry/reploy/internal/canonical" +) + +func testAuthorizationV1() AuthorizationV1 { + digest := canonical.Digest("sha256:" + strings.Repeat("1", 64)) + return AuthorizationV1{ + Schema: AuthorizationSchemaV1, Handle: "session-" + strings.Repeat("a", 64), + DeploymentID: "demo", GenerationReference: "reploy/env/demo:g-current", BuildIdentity: digest, + LiveRunID: "run-0000000000000001", WorkloadPlan: digest, ControllerPlan: digest, + RuntimeIdentity: RuntimeIdentityV1{Username: "reploy", UID: "1000", GID: "1000", SupplementaryGIDs: []string{"10", "100"}}, + Operations: []OperationV1{OperationCompleteV1, OperationInputV1, OperationOpenEndpointV1, OperationResizeV1, OperationTerminateV1}, + EndpointIDs: []string{"browser", "terminal"}, + } +} + +func TestAuthorizationV1ValidatesAndHashesCompletePlan(t *testing.T) { + authorization := testAuthorizationV1() + if err := ValidateAuthorizationV1(authorization); err != nil { + t.Fatalf("ValidateAuthorizationV1() error = %v", err) + } + first, err := AuthorizationDigestV1(authorization) + if err != nil { + t.Fatalf("AuthorizationDigestV1() error = %v", err) + } + authorization.EndpointIDs = []string{"browser"} + second, err := AuthorizationDigestV1(authorization) + if err != nil { + t.Fatalf("AuthorizationDigestV1(mutated) error = %v", err) + } + if first == second { + t.Fatalf("authorization digest did not bind endpoint grants: %s", first) + } +} + +func TestValidateAuthorizationV1AcceptsDockerStyleEndpointIDs(t *testing.T) { + authorization := testAuthorizationV1() + authorization.EndpointIDs = []string{"2fa", "api--v1", "api.v1", "api__internal", "api_v1"} + if err := ValidateAuthorizationV1(authorization); err != nil { + t.Fatalf("ValidateAuthorizationV1() error = %v", err) + } +} + +func TestValidateAuthorizationV1RejectsOpenOrAmbiguousRecords(t *testing.T) { + tests := []struct { + name string + mutate func(*AuthorizationV1) + want string + }{ + {name: "bad handle", mutate: func(value *AuthorizationV1) { value.Handle = "session-guessable" }, want: "64 lowercase"}, + {name: "bad live run", mutate: func(value *AuthorizationV1) { value.LiveRunID = "run-current" }, want: "live-run ID"}, + {name: "unsorted operations", mutate: func(value *AuthorizationV1) { + value.Operations[0], value.Operations[1] = value.Operations[1], value.Operations[0] + }, want: "unique and sorted"}, + {name: "unsorted endpoints", mutate: func(value *AuthorizationV1) { + value.EndpointIDs[0], value.EndpointIDs[1] = value.EndpointIDs[1], value.EndpointIDs[0] + }, want: "unique and sorted"}, + {name: "invalid endpoint", mutate: func(value *AuthorizationV1) { value.EndpointIDs = []string{"API"} }, want: "Docker-style"}, + {name: "endpoint without capability", mutate: func(value *AuthorizationV1) { value.Operations = []OperationV1{OperationCompleteV1} }, want: "require the open-endpoint"}, + {name: "nil collection", mutate: func(value *AuthorizationV1) { value.EndpointIDs = nil }, want: "must use arrays"}, + {name: "unsafe generation", mutate: func(value *AuthorizationV1) { value.GenerationReference = "bad\nreference" }, want: "safe text"}, + {name: "formatted generation", mutate: func(value *AuthorizationV1) { value.GenerationReference = "bad\u202ereference" }, want: "safe text"}, + {name: "leading zero uid", mutate: func(value *AuthorizationV1) { value.RuntimeIdentity.UID = "01000" }, want: "canonical unsigned"}, + {name: "root name for non-root", mutate: func(value *AuthorizationV1) { value.RuntimeIdentity.Username = "root" }, want: "non-root runtime identity"}, + {name: "non-root name for root", mutate: func(value *AuthorizationV1) { value.RuntimeIdentity.UID = "0" }, want: "root runtime identity"}, + {name: "root primary group", mutate: func(value *AuthorizationV1) { value.RuntimeIdentity.GID = "0" }, want: "root group"}, + {name: "root supplementary group", mutate: func(value *AuthorizationV1) { value.RuntimeIdentity.SupplementaryGIDs = []string{"0", "10"} }, want: "root group"}, + {name: "primary supplementary group", mutate: func(value *AuthorizationV1) { value.RuntimeIdentity.SupplementaryGIDs = []string{"10", "1000"} }, want: "exclude the primary"}, + {name: "unsorted numeric groups", mutate: func(value *AuthorizationV1) { value.RuntimeIdentity.SupplementaryGIDs = []string{"100", "10"} }, want: "sorted numerically"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value := testAuthorizationV1() + test.mutate(&value) + err := ValidateAuthorizationV1(value) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("ValidateAuthorizationV1() error = %v, want containing %q", err, test.want) + } + }) + } +} + +func TestNewHandleV1UsesFullRandomCapability(t *testing.T) { + handle, err := newHandleV1(bytes.NewReader(bytes.Repeat([]byte{0xab}, 32))) + if err != nil { + t.Fatalf("newHandleV1() error = %v", err) + } + want := "session-" + strings.Repeat("ab", 32) + if handle != want { + t.Fatalf("newHandleV1() = %q, want %q", handle, want) + } + if _, err := newHandleV1(bytes.NewReader([]byte{1})); err == nil { + t.Fatal("newHandleV1(short randomness) unexpectedly succeeded") + } + if _, err := newHandleV1(nil); err == nil { + t.Fatal("newHandleV1(nil) unexpectedly succeeded") + } +} diff --git a/internal/dockerdeploy/application_sandbox_plan.go b/internal/dockerdeploy/application_sandbox_plan.go index fe29f013..e91fb65f 100644 --- a/internal/dockerdeploy/application_sandbox_plan.go +++ b/internal/dockerdeploy/application_sandbox_plan.go @@ -8,6 +8,7 @@ import ( "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/runtimeidentity" ) const applicationSeccompProfileBuiltinV1 = "builtin" @@ -104,15 +105,6 @@ func ValidateApplicationSandboxPlanV1(plan ApplicationSandboxPlanV1) error { if plan.RuntimeUser.DockerUser != wantUser { return fmt.Errorf("application sandbox Docker user must match its numeric UID and GID") } - if plan.RuntimeUser.LocalUser == "" { - return fmt.Errorf("application sandbox requires a container-local user name") - } - if plan.RuntimeUser.UID == 0 && plan.RuntimeUser.LocalUser != "root" { - return fmt.Errorf("root application sandbox identity must use the local user name root") - } - if plan.RuntimeUser.UID != 0 && plan.RuntimeUser.LocalUser == "root" { - return fmt.Errorf("non-root application sandbox identity must not use the local user name root") - } wantGroups, err := normalizeSupplementaryGIDsV1(plan.RuntimeUser.GID, plan.RuntimeUser.SupplementaryGIDs) if err != nil { return fmt.Errorf("application sandbox supplementary groups: %w", err) @@ -120,10 +112,17 @@ func ValidateApplicationSandboxPlanV1(plan ApplicationSandboxPlanV1) error { if !slices.Equal(plan.RuntimeUser.SupplementaryGIDs, wantGroups) { return fmt.Errorf("application sandbox supplementary groups must be unique, sorted, and exclude the primary GID") } - if plan.RuntimeUser.UID != 0 { - if plan.RuntimeUser.GID == 0 || slices.Contains(plan.RuntimeUser.SupplementaryGIDs, 0) { - return fmt.Errorf("non-root application sandbox identity must not include the root group") - } + identity := runtimeidentity.IdentityV1{ + Username: plan.RuntimeUser.LocalUser, + UID: strconv.Itoa(plan.RuntimeUser.UID), + GID: strconv.Itoa(plan.RuntimeUser.GID), + SupplementaryGIDs: make([]string, len(plan.RuntimeUser.SupplementaryGIDs)), + } + for index, gid := range plan.RuntimeUser.SupplementaryGIDs { + identity.SupplementaryGIDs[index] = strconv.Itoa(gid) + } + if err := runtimeidentity.ValidateIdentityV1(identity); err != nil { + return fmt.Errorf("application sandbox runtime identity: %w", err) } if !plan.ReadOnlyRoot { return fmt.Errorf("application sandbox requires a read-only container root") diff --git a/internal/endpointname/name.go b/internal/endpointname/name.go new file mode 100644 index 00000000..c1797c44 --- /dev/null +++ b/internal/endpointname/name.go @@ -0,0 +1,23 @@ +// Package endpointname defines the shared logical workload-endpoint name +// contract used by blueprint resolution and runtime authorization. +package endpointname + +import ( + "fmt" + "regexp" +) + +const maxLength = 128 + +// componentPattern follows one Docker Distribution image-name path component: +// lowercase alphanumeric segments separated by '.', '_', '__', or one or more +// '-'. It deliberately excludes full reference syntax such as '/', ':', and +// '@'. +var componentPattern = regexp.MustCompile(`^[a-z0-9]+(?:(?:__|[._]|-+)[a-z0-9]+)*$`) + +func Validate(value string) error { + if len(value) == 0 || len(value) > maxLength || !componentPattern.MatchString(value) { + return fmt.Errorf("must be a Docker-style lowercase name component no longer than %d bytes, with alphanumeric segments separated by '.', '_', '__', or one or more '-'", maxLength) + } + return nil +} diff --git a/internal/endpointname/name_test.go b/internal/endpointname/name_test.go new file mode 100644 index 00000000..28d66d86 --- /dev/null +++ b/internal/endpointname/name_test.go @@ -0,0 +1,47 @@ +package endpointname + +import ( + "strings" + "testing" +) + +func TestValidateAcceptsDockerStyleNameComponents(t *testing.T) { + for _, value := range []string{ + "api", + "api_v1", + "api.v1", + "api-v1", + "api--v1", + "api__internal", + "2fa", + strings.Repeat("a", maxLength), + } { + t.Run(value, func(t *testing.T) { + if err := Validate(value); err != nil { + t.Fatalf("Validate(%q) error = %v", value, err) + } + }) + } +} + +func TestValidateRejectsNonComponents(t *testing.T) { + for _, value := range []string{ + "", + "API", + "-api", + "api-", + "api/v1", + "api:v1", + "api@sha256", + "api___v1", + "api..v1", + "café", + strings.Repeat("a", maxLength+1), + } { + t.Run(value, func(t *testing.T) { + if err := Validate(value); err == nil { + t.Fatalf("Validate(%q) unexpectedly succeeded", value) + } + }) + } +} diff --git a/internal/runtimeidentity/identity.go b/internal/runtimeidentity/identity.go new file mode 100644 index 00000000..3bce36bb --- /dev/null +++ b/internal/runtimeidentity/identity.go @@ -0,0 +1,94 @@ +// Package runtimeidentity defines the portable container-local identity +// contract shared by blueprint resolution, runtime planning, and controlled +// sessions. +package runtimeidentity + +import ( + "fmt" + "math" + "strconv" +) + +// POSIX credential-changing calls reserve the all-ones uid_t/gid_t value as +// "leave unchanged". It must never become a planned runtime identity. +const credentialUnchangedSentinelV1 uint64 = math.MaxUint32 + +// IdentityV1 is the canonical, target-independent form of one container-local +// runtime identity. Numeric IDs use decimal strings so the value can be used +// directly in canonical JSON records. +type IdentityV1 struct { + Username string `json:"username"` + UID string `json:"uid"` + GID string `json:"gid"` + SupplementaryGIDs []string `json:"supplementary_gids"` +} + +// ValidateUserName applies Reploy's portable container-local user-name +// grammar. It does not decide whether the selected identity may be root. +func ValidateUserName(value string) error { + if value == "" || len(value) > 32 { + return fmt.Errorf("must be a nonempty portable Unix user name no longer than 32 bytes") + } + for index, character := range value { + if character >= 'a' && character <= 'z' || character == '_' && index == 0 || + index > 0 && (character >= '0' && character <= '9' || character == '_' || character == '-') { + continue + } + return fmt.Errorf("must be a portable lowercase Unix user name") + } + return nil +} + +// ValidateIdentityV1 applies the common numeric and root-group invariants for +// application runtime identities. +func ValidateIdentityV1(identity IdentityV1) error { + if err := ValidateUserName(identity.Username); err != nil { + return fmt.Errorf("runtime username %q %w", identity.Username, err) + } + uid, ok := canonicalUnsignedV1(identity.UID) + if !ok { + return fmt.Errorf("runtime UID must use a canonical unsigned 32-bit decimal string other than math.MaxUint32 (%d)", credentialUnchangedSentinelV1) + } + primaryGID, ok := canonicalUnsignedV1(identity.GID) + if !ok { + return fmt.Errorf("runtime GID must use a canonical unsigned 32-bit decimal string other than math.MaxUint32 (%d)", credentialUnchangedSentinelV1) + } + if uid == 0 && identity.Username != "root" { + return fmt.Errorf("root runtime identity must use the username root") + } + if uid != 0 && identity.Username == "root" { + return fmt.Errorf("non-root runtime identity must not use the username root") + } + if uid != 0 && primaryGID == 0 { + return fmt.Errorf("non-root runtime identity must not use the root group") + } + if identity.SupplementaryGIDs == nil { + return fmt.Errorf("runtime supplementary GIDs must use an array") + } + var previous uint64 + for index, value := range identity.SupplementaryGIDs { + gid, ok := canonicalUnsignedV1(value) + if !ok { + return fmt.Errorf("supplementary GID %q must use a canonical unsigned 32-bit decimal string other than math.MaxUint32 (%d)", value, credentialUnchangedSentinelV1) + } + if gid == primaryGID { + return fmt.Errorf("runtime supplementary GIDs must exclude the primary GID") + } + if uid != 0 && gid == 0 { + return fmt.Errorf("non-root runtime identity must not include the root group") + } + if index > 0 && previous >= gid { + return fmt.Errorf("runtime supplementary GIDs must be unique, sorted numerically, and exclude the primary GID") + } + previous = gid + } + return nil +} + +func canonicalUnsignedV1(value string) (uint64, bool) { + if value == "" || len(value) > 1 && value[0] == '0' { + return 0, false + } + parsed, err := strconv.ParseUint(value, 10, 32) + return parsed, err == nil && parsed != credentialUnchangedSentinelV1 +} diff --git a/internal/runtimeidentity/identity_test.go b/internal/runtimeidentity/identity_test.go new file mode 100644 index 00000000..b79c5dc1 --- /dev/null +++ b/internal/runtimeidentity/identity_test.go @@ -0,0 +1,52 @@ +package runtimeidentity + +import ( + "strings" + "testing" +) + +func TestValidateIdentityV1AcceptsCanonicalRootAndNonRootIdentities(t *testing.T) { + values := []IdentityV1{ + {Username: "reploy", UID: "1000", GID: "1000", SupplementaryGIDs: []string{"10", "100"}}, + {Username: "root", UID: "0", GID: "0", SupplementaryGIDs: []string{"10"}}, + } + for _, value := range values { + if err := ValidateIdentityV1(value); err != nil { + t.Fatalf("ValidateIdentityV1(%+v) error = %v", value, err) + } + } +} + +func TestValidateIdentityV1RejectsNonCanonicalOrPrivilegedNonRootIdentities(t *testing.T) { + base := IdentityV1{Username: "reploy", UID: "1000", GID: "1000", SupplementaryGIDs: []string{"10", "100"}} + tests := []struct { + name string + mutate func(*IdentityV1) + want string + }{ + {name: "invalid username", mutate: func(value *IdentityV1) { value.Username = "Bad" }, want: "portable lowercase"}, + {name: "leading-zero UID", mutate: func(value *IdentityV1) { value.UID = "01000" }, want: "runtime UID"}, + {name: "unchanged UID sentinel", mutate: func(value *IdentityV1) { value.UID = "4294967295" }, want: "runtime UID"}, + {name: "overflow GID", mutate: func(value *IdentityV1) { value.GID = "4294967296" }, want: "runtime GID"}, + {name: "unchanged GID sentinel", mutate: func(value *IdentityV1) { value.GID = "4294967295" }, want: "runtime GID"}, + {name: "root name for non-root", mutate: func(value *IdentityV1) { value.Username = "root" }, want: "non-root runtime identity"}, + {name: "non-root name for root", mutate: func(value *IdentityV1) { value.UID = "0" }, want: "root runtime identity"}, + {name: "root primary group", mutate: func(value *IdentityV1) { value.GID = "0" }, want: "root group"}, + {name: "nil groups", mutate: func(value *IdentityV1) { value.SupplementaryGIDs = nil }, want: "must use an array"}, + {name: "primary group repeated", mutate: func(value *IdentityV1) { value.SupplementaryGIDs = []string{"1000"} }, want: "exclude the primary"}, + {name: "root supplementary group", mutate: func(value *IdentityV1) { value.SupplementaryGIDs = []string{"0", "10"} }, want: "root group"}, + {name: "unchanged supplementary GID sentinel", mutate: func(value *IdentityV1) { value.SupplementaryGIDs = []string{"10", "4294967295"} }, want: "supplementary GID"}, + {name: "unsorted groups", mutate: func(value *IdentityV1) { value.SupplementaryGIDs = []string{"100", "10"} }, want: "unique, sorted"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value := base + value.SupplementaryGIDs = append([]string(nil), base.SupplementaryGIDs...) + test.mutate(&value) + err := ValidateIdentityV1(value) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("ValidateIdentityV1() error = %v, want containing %q", err, test.want) + } + }) + } +}