diff --git a/.changes/unreleased/+workload-endpoint-identifiers.yaml b/.changes/unreleased/+workload-endpoint-identifiers.yaml new file mode 100644 index 0000000..80055a2 --- /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 19f2308..6d98353 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 1288d75..d9c0a4d 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 2130ceb..8ef65cb 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 8f385d1..675f7fa 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 475bd9c..9922cc6 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 0000000..2eaa476 --- /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 0000000..3873b1a --- /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 fe29f01..e91fb65 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 0000000..c1797c4 --- /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 0000000..28d66d8 --- /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 0000000..3bce36b --- /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 0000000..b79c5dc --- /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) + } + }) + } +}