diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index fbb0ceb1..f8c012db 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -5,11 +5,32 @@ permissions: jobs: bootstrap: runs-on: ubuntu-24.04 + timeout-minutes: 35 steps: # pull_request checks GitHub's ordinary synthetic merge into main. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false + # Native tool installers only; no product runtime, containers or activation. + # SHAs resolved from the official repositories' tag API on 2026-10-14. + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.14.8' + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: '1.27.2' + cache: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '26.11.1' + package-manager-cache: false + - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1 + with: + version-type: strict + otp-version: '29.1.1' + elixir-version: '1.20.4' + install-hex: false + install-rebar: false - name: Validate explicit repository acceptance run: python3 -I -B scripts/check_ci.py diff --git a/.gitignore b/.gitignore index b13101f3..c67a12fc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ __pycache__/ .codex/auth.json .codex/sessions/ WORKFLOW.local.md + +# Local Go scaffold executables (build acceptance normally targets a temp dir). +/apps/host_agent/workharbor-agent +/apps/whr/whr diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..6c8d4ae5 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,5 @@ +erlang 29.1.1 +elixir 1.20.4-otp-29 +golang 1.27.2 +nodejs 26.11.1 +python 3.14.8 diff --git a/Makefile b/Makefile index b2259ff1..bb2f061b 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,11 @@ PYTHON ?= python3 -.PHONY: check check-raw test +.PHONY: check check-raw check-apps test check: $(PYTHON) -I -B scripts/check_ci.py +check-apps: + $(PYTHON) -I -B scripts/check_ci.py --applications-only + check-raw: $(PYTHON) scripts/check_repo.py $(PYTHON) -m unittest discover -s tests -v diff --git a/README.md b/README.md index 76d8cd9c..d0858aeb 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ make check The bundle must be supplied separately; it is not required for the GitHub clone. -Requires Git, Make, Python 3.11+ and Bash. On macOS the built-in Python may need a +Requires Git, Make, Bash, native C build tools and the exact versions in +`.tool-versions` (Python, Go, Node, Elixir and Erlang). The application check fetches +locked dependencies and pinned build tools; see the [harness](docs/development/harness.md). On macOS the built-in Python may need a newer installation. Open Codex in this repository; generated `$speckit-*` skills live under `.agents/skills/`. Read [AGENTS.md](AGENTS.md), the [constitution](.specify/memory/constitution.md) and [documentation index](docs/index.md). diff --git a/apps/api_client/doc.go b/apps/api_client/doc.go new file mode 100644 index 00000000..864bc19d --- /dev/null +++ b/apps/api_client/doc.go @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Package apiclient reserves the shared owner API client boundary for whr and the +// host agent. Transport and authentication are not implemented yet. +package apiclient diff --git a/apps/api_client/go.mod b/apps/api_client/go.mod new file mode 100644 index 00000000..8dd05c6a --- /dev/null +++ b/apps/api_client/go.mod @@ -0,0 +1,3 @@ +module github.com/wstein/workharbor-nxt/apps/api_client + +go 1.27.2 diff --git a/apps/api_client/wirejson/wirejson.go b/apps/api_client/wirejson/wirejson.go new file mode 100644 index 00000000..bab9abcc --- /dev/null +++ b/apps/api_client/wirejson/wirejson.go @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Package wirejson implements WorkHarbor's selected integer-only JSON profile. +// It is not RFC 8785/JCS. See contracts/migration for the frozen source profile +// and deliberate product restrictions. These primitives grant no authority. +package wirejson + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "math/big" + "sort" + "strconv" + "strings" + "unicode/utf8" +) + +const ( + MaxInputBytes = 1 << 20 + MaxIntegerDigits = 4300 + MaxContainerDepth = 128 + CodeInvalidJSON = "invalid-json" + CodeInputLimitExceeded = "input-limit-exceeded" +) + +// Error reports a fixed diagnostic without exposing input or decoder details. +type Error struct{ Code string } + +func (e *Error) Error() string { return e.Code } +func invalid() error { return &Error{Code: CodeInvalidJSON} } + +// Parse accepts exactly []byte, including a typed nil slice (which is invalid +// JSON). Other native types are invalid-json before any size inspection. Bytes +// above MaxInputBytes are input-limit-exceeded before UTF-8/syntax validation. +// The result consists of nil, bool, string, *big.Int, []any and map[string]any. +// Integers retain arbitrary precision within MaxIntegerDigits magnitude digits. +// Objects reject duplicate decoded keys; containers are bounded to depth 128. +func Parse(data any) (any, error) { + raw, ok := data.([]byte) + if !ok { + return nil, invalid() + } + if len(raw) > MaxInputBytes { + return nil, &Error{Code: CodeInputLimitExceeded} + } + // encoding/json substitutes malformed UTF-8 and unpaired surrogates. Validate + // before tokenization so neither can become a valid replacement character. + if !utf8.Valid(raw) || !json.Valid(raw) || !scalarEscapes(raw) { + return nil, invalid() + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + value, err := readValue(decoder, 0) + if err != nil { + return nil, invalid() + } + if _, err = decoder.Token(); err != io.EOF { + return nil, invalid() + } + return value, nil +} + +// scalarEscapes runs only on syntactically valid JSON. Escaped backslashes must +// not turn a literal "\uD800" into a Unicode escape; paired escapes are consumed +// together. Literal UTF-8 has already been checked separately. +func scalarEscapes(raw []byte) bool { + for i := 0; i < len(raw); i++ { + if raw[i] != '"' { + continue + } + i++ + for raw[i] != '"' { + if raw[i] != '\\' { + i++ + continue + } + i++ + if raw[i] != 'u' { + i++ + continue + } + first := hexQuad(raw[i+1 : i+5]) + i += 5 + if first >= 0xd800 && first <= 0xdbff { + if i+6 > len(raw) || raw[i] != '\\' || raw[i+1] != 'u' { + return false + } + second := hexQuad(raw[i+2 : i+6]) + if second < 0xdc00 || second > 0xdfff { + return false + } + i += 6 + } else if first >= 0xdc00 && first <= 0xdfff { + return false + } + } + } + return true +} +func hexQuad(raw []byte) uint16 { + var n uint16 + for _, b := range raw { + n <<= 4 + switch { + case b >= '0' && b <= '9': + n += uint16(b - '0') + case b >= 'a' && b <= 'f': + n += uint16(b - 'a' + 10) + default: + n += uint16(b - 'A' + 10) + } + } + return n +} +func readValue(decoder *json.Decoder, depth int) (any, error) { + token, err := decoder.Token() + if err != nil { + return nil, err + } + switch v := token.(type) { + case nil, bool, string: + return v, nil + case json.Number: + text := string(v) + if strings.ContainsAny(text, ".eE") || magnitudeDigits(text) > MaxIntegerDigits { + return nil, invalid() + } + n, ok := new(big.Int).SetString(text, 10) + if !ok { + return nil, invalid() + } + return n, nil + case json.Delim: + if depth >= MaxContainerDepth { + return nil, invalid() + } + switch v { + case '[': + result := []any{} + for decoder.More() { + child, e := readValue(decoder, depth+1) + if e != nil { + return nil, e + } + result = append(result, child) + } + close, e := decoder.Token() + if e != nil || close != json.Delim(']') { + return nil, invalid() + } + return result, nil + case '{': + result := map[string]any{} + for decoder.More() { + key, e := decoder.Token() + if e != nil { + return nil, e + } + name, ok := key.(string) + if !ok { + return nil, invalid() + } + if _, exists := result[name]; exists { + return nil, invalid() + } + child, e := readValue(decoder, depth+1) + if e != nil { + return nil, e + } + result[name] = child + } + close, e := decoder.Token() + if e != nil || close != json.Delim('}') { + return nil, invalid() + } + return result, nil + } + } + return nil, invalid() +} +func magnitudeDigits(text string) int { + if strings.HasPrefix(text, "-") { + return len(text) - 1 + } + return len(text) +} + +// CanonicalBytes accepts exact native nil, bool, string, []any, map[string]any, +// *big.Int and built-in int/int8/int16/int32/int64/uint/uint8/uint16/uint32/uint64. +// Defined types, uintptr, arrays, byte slices, json.Number, floats, structs and +// pointers other than nonnil *big.Int are invalid-json; custom marshalers never +// execute. Nil exact slices/maps encode as []/{}. Strings/keys require valid +// UTF-8. No normalization, escaping of HTML/U+2028/U+2029 or trailing LF occurs. +// Depth/digit limits also apply here, and cycles fail within the depth bound. +// There is no canonical output byte-size cap. Callers must not mutate values +// concurrently while they are being read. +func CanonicalBytes(value any) ([]byte, error) { + result, err := appendValue(nil, value, 0) + if err != nil { + return nil, err + } + return result, nil +} + +// CanonicalDigest is lowercase SHA-256 of CanonicalBytes, with identical input +// validation. It inherits no parse-input byte-size cap. +func CanonicalDigest(value any) (string, error) { + canonical, err := CanonicalBytes(value) + if err != nil { + return "", err + } + digest := sha256.Sum256(canonical) + return hex.EncodeToString(digest[:]), nil +} +func appendValue(out []byte, value any, depth int) ([]byte, error) { + switch v := value.(type) { + case nil: + return append(out, "null"...), nil + case bool: + if v { + return append(out, "true"...), nil + } + return append(out, "false"...), nil + case string: + return appendString(out, v) + case []any: + if depth >= MaxContainerDepth { + return nil, invalid() + } + out = append(out, '[') + for i, child := range v { + if i > 0 { + out = append(out, ',') + } + var err error + out, err = appendValue(out, child, depth+1) + if err != nil { + return nil, err + } + } + return append(out, ']'), nil + case map[string]any: + if depth >= MaxContainerDepth { + return nil, invalid() + } + keys := make([]string, 0, len(v)) + for key := range v { + if !utf8.ValidString(key) { + return nil, invalid() + } + keys = append(keys, key) + } + // Valid UTF-8 byte ordering is Unicode scalar ordering, unlike UTF-16. + sort.Strings(keys) + out = append(out, '{') + for i, key := range keys { + if i > 0 { + out = append(out, ',') + } + var err error + out, err = appendString(out, key) + if err != nil { + return nil, err + } + out = append(out, ':') + out, err = appendValue(out, v[key], depth+1) + if err != nil { + return nil, err + } + } + return append(out, '}'), nil + default: + text, ok := integerText(value) + if !ok || magnitudeDigits(text) > MaxIntegerDigits { + return nil, invalid() + } + return append(out, text...), nil + } +} +func integerText(value any) (string, bool) { + switch v := value.(type) { + case *big.Int: + if v != nil { + return v.String(), true + } + case int: + return strconv.FormatInt(int64(v), 10), true + case int8: + return strconv.FormatInt(int64(v), 10), true + case int16: + return strconv.FormatInt(int64(v), 10), true + case int32: + return strconv.FormatInt(int64(v), 10), true + case int64: + return strconv.FormatInt(v, 10), true + case uint: + return strconv.FormatUint(uint64(v), 10), true + case uint8: + return strconv.FormatUint(uint64(v), 10), true + case uint16: + return strconv.FormatUint(uint64(v), 10), true + case uint32: + return strconv.FormatUint(uint64(v), 10), true + case uint64: + return strconv.FormatUint(v, 10), true + } + return "", false +} +func appendString(out []byte, text string) ([]byte, error) { + if !utf8.ValidString(text) { + return nil, invalid() + } + const digits = "0123456789abcdef" + out = append(out, '"') + for i := 0; i < len(text); i++ { + b := text[i] + switch b { + case '"', '\\': + out = append(out, '\\', b) + case '\b': + out = append(out, `\b`...) + case '\f': + out = append(out, `\f`...) + case '\n': + out = append(out, `\n`...) + case '\r': + out = append(out, `\r`...) + case '\t': + out = append(out, `\t`...) + default: + if b < 0x20 { + out = append(out, '\\', 'u', '0', '0', digits[b>>4], digits[b&15]) + } else { + out = append(out, b) + } + } + } + return append(out, '"'), nil +} diff --git a/apps/api_client/wirejson/wirejson_test.go b/apps/api_client/wirejson/wirejson_test.go new file mode 100644 index 00000000..1f447ffb --- /dev/null +++ b/apps/api_client/wirejson/wirejson_test.go @@ -0,0 +1,443 @@ +// SPDX-License-Identifier: EUPL-1.2 +package wirejson_test + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "math/big" + "os" + "reflect" + "strings" + "testing" + "unicode/utf8" + + "github.com/wstein/workharbor-nxt/apps/api_client/wirejson" +) + +type byteVector struct { + Encoding string `json:"encoding"` + Hex string `json:"hex"` + Prefix string `json:"prefix_hex"` + Unit string `json:"unit_hex"` + Suffix string `json:"suffix_hex"` + Count int `json:"count"` + Length int `json:"length"` + SHA string `json:"sha256"` +} + +func (v byteVector) expand(t *testing.T) []byte { + t.Helper() + decode := func(s string) []byte { + b, e := hex.DecodeString(s) + if e != nil { + t.Fatal(e) + } + return b + } + var b []byte + switch v.Encoding { + case "hex": + b = decode(v.Hex) + case "repeat": + b = append(decode(v.Prefix), bytes.Repeat(decode(v.Unit), v.Count)...) + b = append(b, decode(v.Suffix)...) + default: + t.Fatalf("unknown bytes encoding %q", v.Encoding) + } + if len(b) != v.Length || fmt.Sprintf("%x", sha256.Sum256(b)) != v.SHA { + t.Fatal("frozen byte materialization mismatch") + } + return b +} + +type nativeVector struct { + Kind string `json:"kind"` + Value json.RawMessage `json:"value"` + Decimal string `json:"decimal"` + Exponent int `json:"exponent"` + Negative bool `json:"negative"` + Codepoints []int `json:"codepoints"` + Codepoint int `json:"codepoint"` + Count int `json:"count"` + Data byteVector `json:"data"` + Items []nativeVector `json:"items"` + Entries []struct { + Key nativeVector `json:"key"` + Value nativeVector `json:"value"` + } `json:"entries"` + Depth int `json:"depth"` + Shape string `json:"shape"` + Leaf *nativeVector `json:"leaf"` +} + +// bytearray is a distinct native type, never silently converted to accepted []byte. +type bytearray []byte + +type hostileMarshaler struct{} + +func (hostileMarshaler) MarshalJSON() ([]byte, error) { panic("custom marshaler must never execute") } + +func scalarString(points []int) string { + var out []byte + for _, p := range points { + if p >= 0xd800 && p <= 0xdfff { + // Surrogates have no scalar-string Go representation. Preserve their bytes + // as invalid UTF-8 so real production validation rejects them, not U+FFFD. + out = append(out, byte(0xe0|(p>>12)), byte(0x80|((p>>6)&63)), byte(0x80|(p&63))) + } else { + out = utf8.AppendRune(out, rune(p)) + } + } + return string(out) +} +func (v nativeVector) materialize(t *testing.T) any { + t.Helper() + switch v.Kind { + case "null": + return nil + case "boolean": + var b bool + if e := json.Unmarshal(v.Value, &b); e != nil { + t.Fatal(e) + } + return b + case "integer": + b, ok := new(big.Int).SetString(v.Decimal, 10) + if !ok { + t.Fatal("bad integer fixture") + } + return b + case "power10": + b := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(v.Exponent)), nil) + if v.Negative { + b.Neg(b) + } + return b + case "string": + return scalarString(v.Codepoints) + case "repeat-string": + return strings.Repeat(string(rune(v.Codepoint)), v.Count) + case "bytes": + return v.Data.expand(t) + case "bytearray": + return bytearray(v.Data.expand(t)) + case "float": + var s string + if e := json.Unmarshal(v.Value, &s); e != nil { + t.Fatal(e) + } + switch s { + case "1.5": + return 1.5 + case "nan": + return math.NaN() + case "inf": + return math.Inf(1) + case "-inf": + return math.Inf(-1) + } + case "list", "tuple": + items := make([]any, len(v.Items)) + for i, x := range v.Items { + items[i] = x.materialize(t) + } + if v.Kind == "list" { + return items + } + array := reflect.New(reflect.ArrayOf(len(items), reflect.TypeFor[any]())).Elem() + for i, x := range items { + if x != nil { + array.Index(i).Set(reflect.ValueOf(x)) + } + } + return array.Interface() + case "object": + object := map[string]any{} + other := map[any]any{} + stringsOnly := true + for _, entry := range v.Entries { + k, x := entry.Key.materialize(t), entry.Value.materialize(t) + other[k] = x + if s, ok := k.(string); ok { + object[s] = x + } else { + stringsOnly = false + } + } + if stringsOnly { + return object + } + return other + case "nested": + x := v.Leaf.materialize(t) + for i := 0; i < v.Depth; i++ { + if v.Shape == "object" || (v.Shape == "alternating" && i%2 == 1) { + x = map[string]any{"x": x} + } else { + x = []any{x} + } + } + return x + } + t.Fatalf("unhandled native kind %q", v.Kind) + return nil +} +func requireCode(t *testing.T, err error, code string) { + t.Helper() + var e *wirejson.Error + if !errors.As(err, &e) || e.Code != code || err.Error() != code { + t.Fatalf("error=%v, want safe code %s", err, code) + } +} +func TestFrozenJSONVectors(t *testing.T) { + raw, e := os.ReadFile("../../../contracts/migration/vectors.json") + if e != nil { + t.Fatal(e) + } + if fmt.Sprintf("%x", sha256.Sum256(raw)) != "0070cf8057253dca19abe7eaaa55e9c9e17313ba53c6cd820014443e3708a04c" { + t.Fatal("frozen vector hash changed") + } + var corpus struct { + Cases []struct { + ID string `json:"id"` + Operation string `json:"operation"` + Input nativeVector `json:"input"` + Expected struct { + Status string `json:"status"` + Code string `json:"code"` + Canonical byteVector `json:"canonical"` + Digest string `json:"digest"` + } `json:"product_expected"` + } `json:"cases"` + } + if e = json.Unmarshal(raw, &corpus); e != nil { + t.Fatal(e) + } + count := 0 + for _, c := range corpus.Cases { + if c.Operation == "canonical-path" { + continue + } + count++ + t.Run(c.ID, func(t *testing.T) { + v := c.Input.materialize(t) + var output []byte + var digest string + var err error + switch c.Operation { + case "parse-json": + v, err = wirejson.Parse(v) + case "canonical-bytes": + output, err = wirejson.CanonicalBytes(v) + case "canonical-digest": + digest, err = wirejson.CanonicalDigest(v) + default: + t.Fatal("unknown operation") + } + // Assert the selected operation before any follow-on operation can + // mask a wrongly accepted input with its own rejection. + if c.Expected.Status == "reject" { + requireCode(t, err, c.Expected.Code) + return + } + if err != nil { + t.Fatal(err) + } + if c.Operation != "canonical-bytes" { + output, err = wirejson.CanonicalBytes(v) + if err != nil { + t.Fatal(err) + } + } + if c.Operation != "canonical-digest" { + digest, err = wirejson.CanonicalDigest(v) + if err != nil { + t.Fatal(err) + } + } + if !bytes.Equal(output, c.Expected.Canonical.expand(t)) || digest != c.Expected.Digest { + t.Fatalf("canonical bytes/digest mismatch for %s", c.ID) + } + }) + } + if count != 113 { + t.Fatalf("executed %d JSON vectors, want 113", count) + } +} +func TestStrictJSONAdversarial(t *testing.T) { + bad := []string{`{"x":[{"a":0,"\u0061":1}]}`, `{"\ud800":0}`, `["\udfff"]`, `"\ud800\u0041"`, `"\udc00\ud800"`, `"\ud800\\udc00"`, `true false`, `0 0`, `[01]`, `1e0`, `1.0`, "\xef\xbb\xbfnull", "\"\xed\xa0\x80\"", "\"\xf0\x80\x80\x80\"", `{"a":1,}`, `[]garbage`} + for _, s := range bad { + t.Run(fmt.Sprintf("%x", s), func(t *testing.T) { _, e := wirejson.Parse([]byte(s)); requireCode(t, e, "invalid-json") }) + } + good := map[string]string{`"\ud800\udc00"`: "\"π\"", `"\ufffd"`: "\"οΏ½\"", `{"οΏ½":1}`: `{"οΏ½":1}`, `"\\ud800"`: `"\\ud800"`, " \r\n\t [0,true] \n": "[0,true]", `{"\ud800\udc00":0}`: `{"π":0}`} + for s, want := range good { + v, e := wirejson.Parse([]byte(s)) + if e != nil { + t.Fatalf("%q: %v", s, e) + } + got, e := wirejson.CanonicalBytes(v) + if e != nil || string(got) != want { + t.Fatalf("%q: %q %v", s, got, e) + } + } + _, e := wirejson.Parse(bytes.Repeat([]byte{0xff}, 1048577)) + requireCode(t, e, "input-limit-exceeded") + _, e = wirejson.Parse(bytearray(bytes.Repeat([]byte{0xff}, 1048577))) + requireCode(t, e, "invalid-json") +} +func TestCanonicalNativeDomain(t *testing.T) { + type namedString string + type namedInteger int + type namedList []any + type namedMap map[string]any + var n *big.Int + bad := []any{hostileMarshaler{}, uintptr(1), float32(1), float64(1), math.NaN(), math.Inf(-1), json.Number("1"), []byte("a"), [1]any{1}, map[int]any{1: 0}, map[any]any{"a": 0}, struct{ A int }{1}, namedString("a"), namedInteger(1), namedList{}, namedMap{}, n, (*string)(nil), string([]byte{0xff}), map[string]any{string([]byte{0xed, 0xa0, 0x80}): nil}} + cycleList := make([]any, 1) + cycleList[0] = cycleList + cycleMap := map[string]any{} + cycleMap["x"] = cycleMap + bad = append(bad, cycleList, cycleMap) + for i, v := range bad { + t.Run(fmt.Sprint(i), func(t *testing.T) { + _, e := wirejson.CanonicalBytes(v) + requireCode(t, e, "invalid-json") + _, e = wirejson.CanonicalDigest(v) + requireCode(t, e, "invalid-json") + }) + } + for _, v := range []any{int(42), int8(42), int16(42), int32(42), int64(42), uint(42), uint8(42), uint16(42), uint32(42), uint64(42), big.NewInt(42)} { + b, e := wirejson.CanonicalBytes(v) + if e != nil || string(b) != "42" { + t.Fatalf("%T: %q %v", v, b, e) + } + } + for _, c := range []struct { + v any + want string + }{{nil, "null"}, {[]any(nil), "[]"}, {map[string]any(nil), "{}"}, {false, "false"}, {"\b\f\n\r\t\x00\x1f\"\\/<>\u2028\u2029", `"\b\f\n\r\t\u0000\u001f\"\\/<>` + "\u2028\u2029" + `"`}} { + b, e := wirejson.CanonicalBytes(c.v) + if e != nil || string(b) != c.want { + t.Fatalf("%T: %q want %q: %v", c.v, b, c.want, e) + } + } + large := strings.Repeat("x", 1048577) + b, e := wirejson.CanonicalBytes(large) + if e != nil || len(b) != 1048579 { + t.Fatal("canonical output incorrectly bounded", e) + } +} +func TestIntegerAndContainerLimits(t *testing.T) { + for _, digits := range []int{4300, 4301} { + for _, negative := range []bool{false, true} { + s := strings.Repeat("9", digits) + if negative { + s = "-" + s + } + n, ok := new(big.Int).SetString(s, 10) + if !ok { + t.Fatal("test integer") + } + _, pe := wirejson.Parse([]byte(s)) + b, ce := wirejson.CanonicalBytes(n) + d, de := wirejson.CanonicalDigest(n) + if digits == 4301 { + for _, e := range []error{pe, ce, de} { + requireCode(t, e, "invalid-json") + } + } else if pe != nil || ce != nil || de != nil || string(b) != s || d != fmt.Sprintf("%x", sha256.Sum256([]byte(s))) { + t.Fatal("4300 digit boundary failure") + } + } + } + for _, depth := range []int{128, 129} { + var v any = nil + for i := 0; i < depth; i++ { + if i%2 == 0 { + v = []any{v} + } else { + v = map[string]any{"x": v} + } + } + b, e := wirejson.CanonicalBytes(v) + _, de := wirejson.CanonicalDigest(v) + if depth == 129 { + requireCode(t, e, "invalid-json") + requireCode(t, de, "invalid-json") + } else { + if e != nil || de != nil { + t.Fatal(e, de) + } + if _, e = wirejson.Parse(b); e != nil { + t.Fatal(e) + } + } + } + // Assert Parse's own result for every container shape, independent of + // canonicalization, so a later encoder rejection cannot mask acceptance. + for _, shape := range []string{"array", "object", "alternating"} { + for _, depth := range []int{128, 129} { + raw := "0" + for i := 0; i < depth; i++ { + if shape == "object" || (shape == "alternating" && i%2 == 1) { + raw = `{"x":` + raw + `}` + } else { + raw = "[" + raw + "]" + } + } + _, err := wirejson.Parse([]byte(raw)) + if depth == 129 { + requireCode(t, err, "invalid-json") + } else if err != nil { + t.Fatalf("%s depth128: %v", shape, err) + } + } + } + + v, e := wirejson.Parse([]byte("[true,1,-0]")) + if e != nil { + t.Fatal(e) + } + a := v.([]any) + if _, ok := a[0].(bool); !ok { + t.Fatal("boolean coerced") + } + if a[1].(*big.Int).Cmp(big.NewInt(1)) != 0 || a[2].(*big.Int).Sign() != 0 { + t.Fatal("integer domain lost") + } +} +func FuzzParseCanonicalRoundTrip(f *testing.F) { + for _, s := range []string{`null`, `{"a":[true,-0,1]}`, `"\ud800"`, `{"a":0,"\u0061":1}`, `"\ud800\udc00"`, "\"οΏ½\u2028\""} { + f.Add([]byte(s)) + } + f.Fuzz(func(t *testing.T, input []byte) { + v, e := wirejson.Parse(input) + if e != nil { + var safe *wirejson.Error + if !errors.As(e, &safe) { + t.Fatalf("unsafe error %v", e) + } + return + } + canonical, e := wirejson.CanonicalBytes(v) + if e != nil { + t.Fatal(e) + } + again, e := wirejson.Parse(canonical) + if e != nil { + t.Fatal(e) + } + stable, e := wirejson.CanonicalBytes(again) + if e != nil || !bytes.Equal(canonical, stable) { + t.Fatal("unstable canonicalization", e) + } + d, e := wirejson.CanonicalDigest(v) + if e != nil || d != fmt.Sprintf("%x", sha256.Sum256(canonical)) { + t.Fatal("digest disagreement", e) + } + }) +} diff --git a/apps/control_plane/.formatter.exs b/apps/control_plane/.formatter.exs new file mode 100644 index 00000000..ef8840ce --- /dev/null +++ b/apps/control_plane/.formatter.exs @@ -0,0 +1,6 @@ +[ + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] +] diff --git a/apps/control_plane/.gitignore b/apps/control_plane/.gitignore new file mode 100644 index 00000000..db499fd8 --- /dev/null +++ b/apps/control_plane/.gitignore @@ -0,0 +1,40 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +workharbor-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ + +# Database files +*.db +*.db-* diff --git a/apps/control_plane/README.md b/apps/control_plane/README.md new file mode 100644 index 00000000..1bd5b3a4 --- /dev/null +++ b/apps/control_plane/README.md @@ -0,0 +1,61 @@ +# WorkHarbor control plane + +The Phoenix/Ecto/SQLite control plane supplies durable metadata and an owner +authentication foundation. Lifecycle dispatch, providers, host enrollment and +product management/Spec Kit data views remain later work. The locked dependencies +and root `.tool-versions` define the tested toolchains. + +Verify from this directory without starting a server: + +```sh +mix deps.get --check-locked +mix format --check-formatted +mix compile --warnings-as-errors +mix test +``` + +Tests use file-backed databases and `server: false` by default. The bounded +transport suite starts disposable loopback TLS listeners with generated test +certificates; authentication requests use a shared sandbox transaction. Foundation tests +include independent writers and bounded SIGKILL/reopen evidence; these do not +establish hardware power-loss or live-platform acceptance. + +## Authentication foundation + +[The authentication contract](../../specs/006-whr-cli/contracts/authentication.md) +defines primary secret delivery, opaque scoped credentials, strict raw JSON and +browser admission. Local `WorkHarbor.Accounts.bootstrap/1` takes a protected +one-time delivery callback; it persists hashes only and stays initialized if +delivery fails. There is no HTTP bootstrap/reset flow. Do not print the delivered +secret, place it in a URL/argument or put it in repository configuration. + +`/login/owner`, `/login/dashboard` and `/login/read-token` supply bounded no-script +forms. Owner and dashboard have separate selected cookies; public login uses a +third preauth cookie with server-checked 600-second expiry. Owner management can +explicitly install dashboard sign-in while retaining its owner session. Dashboard +sign-in cannot mutate, mint or revoke credentials. βClear dashboard sign-inβ only +clears that cookie; βSign out of owner managementβ revokes the selected owner +credential. Raw read-token issuance is limited to the CLI bearer secret channel. + +No listener starts by default. An independently authorized operator activation +requires `PHX_SERVER=true`, `WORKHARBOR_ORIGIN`, `WORKHARBOR_BIND`, +`WORKHARBOR_TLS_CERT_FILE`, `WORKHARBOR_TLS_KEY_FILE`, and a private non-demo +`SECRET_KEY_BASE`; production also requires `DATABASE_PATH`. Direct TLS origin, +authority and listener port must agree. HTTP, proxy-forwarded TLS, debugger, +reloader and demo signing secrets are rejected for an active server. Every active +environment uses the narrow explicit HTTP/1-only Bandit profile and rejects generic +listener/handler/ALPN overrides. Locked Bandit 1.12.5 remains affected by +[GHSA-69m6-wcv4-rwcf](https://github.com/mtrudel/bandit/security/advisories/GHSA-69m6-wcv4-rwcf); +[the transport contract](../../specs/006-whr-cli/contracts/transport-security.md) +records this temporary mitigation and the separate review required to re-enable +HTTP/2 after a released upstream fix. This document +records configuration requirements, not permission to activate or deploy. + +The inherited root welcome test retains its required ID and now verifies the +intentional plaintext rejection. Authenticated root navigation has separate +positive coverage. ConnTest and socket adapter tests establish application gates, +not actual network TLS or browser acceptance. The separate native transport suite +proves TLS certificate/hostname verification, HTTP/2 refusal, HTTP/1 authentication +and WSS admission on ephemeral loopback listeners. It does not establish a deployed +listener, browser walkthrough, real-host enrollment or pilot completion. Assets retain the pinned Mix wrappers; +no new runtime dependencies or Python/AI validation subprocesses were added. diff --git a/apps/control_plane/assets/css/app.css b/apps/control_plane/assets/css/app.css new file mode 100644 index 00000000..91ba02d4 --- /dev/null +++ b/apps/control_plane/assets/css/app.css @@ -0,0 +1,105 @@ +/* See the Tailwind configuration guide for advanced usage + https://tailwindcss.com/docs/configuration */ + +@import "tailwindcss" source(none); +@import "phoenix-colocated/workharbor/colocated.css"; +@source "../css"; +@source "../js"; +@source "../../lib/workharbor_web"; +/* Required for Tailwind to automatically pick up changes in colocated CSS files in dev */ +@source "../../_build/dev/phoenix-colocated/workharbor/*/"; + +/* A Tailwind plugin that makes "hero-#{ICON}" classes available. + The heroicons installation itself is managed by your mix.exs */ +@plugin "../vendor/heroicons"; + +/* daisyUI Tailwind Plugin. */ +@plugin "daisyui/packages/bundle/daisyui" { + themes: false; +} + +/* daisyUI theme plugin. + We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired + on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */ +@plugin "daisyui/packages/bundle/daisyui-theme" { + name: "dark"; + default: false; + prefersdark: true; + color-scheme: "dark"; + --color-base-100: oklch(30.33% 0.016 252.42); + --color-base-200: oklch(25.26% 0.014 253.1); + --color-base-300: oklch(20.15% 0.012 254.09); + --color-base-content: oklch(97.807% 0.029 256.847); + --color-primary: oklch(58% 0.233 277.117); + --color-primary-content: oklch(96% 0.018 272.314); + --color-secondary: oklch(58% 0.233 277.117); + --color-secondary-content: oklch(96% 0.018 272.314); + --color-accent: oklch(60% 0.25 292.717); + --color-accent-content: oklch(96% 0.016 293.756); + --color-neutral: oklch(37% 0.044 257.287); + --color-neutral-content: oklch(98% 0.003 247.858); + --color-info: oklch(58% 0.158 241.966); + --color-info-content: oklch(97% 0.013 236.62); + --color-success: oklch(60% 0.118 184.704); + --color-success-content: oklch(98% 0.014 180.72); + --color-warning: oklch(66% 0.179 58.318); + --color-warning-content: oklch(98% 0.022 95.277); + --color-error: oklch(58% 0.253 17.585); + --color-error-content: oklch(96% 0.015 12.422); + --radius-selector: 0.25rem; + --radius-field: 0.25rem; + --radius-box: 0.5rem; + --size-selector: 0.21875rem; + --size-field: 0.21875rem; + --border: 1.5px; + --depth: 1; + --noise: 0; +} + +@plugin "daisyui/packages/bundle/daisyui-theme" { + name: "light"; + default: true; + prefersdark: false; + color-scheme: "light"; + --color-base-100: oklch(98% 0 0); + --color-base-200: oklch(96% 0.001 286.375); + --color-base-300: oklch(92% 0.004 286.32); + --color-base-content: oklch(21% 0.006 285.885); + --color-primary: oklch(70% 0.213 47.604); + --color-primary-content: oklch(98% 0.016 73.684); + --color-secondary: oklch(55% 0.027 264.364); + --color-secondary-content: oklch(98% 0.002 247.839); + --color-accent: oklch(0% 0 0); + --color-accent-content: oklch(100% 0 0); + --color-neutral: oklch(44% 0.017 285.786); + --color-neutral-content: oklch(98% 0 0); + --color-info: oklch(62% 0.214 259.815); + --color-info-content: oklch(97% 0.014 254.604); + --color-success: oklch(70% 0.14 182.503); + --color-success-content: oklch(98% 0.014 180.72); + --color-warning: oklch(66% 0.179 58.318); + --color-warning-content: oklch(98% 0.022 95.277); + --color-error: oklch(58% 0.253 17.585); + --color-error-content: oklch(96% 0.015 12.422); + --radius-selector: 0.25rem; + --radius-field: 0.25rem; + --radius-box: 0.5rem; + --size-selector: 0.21875rem; + --size-field: 0.21875rem; + --border: 1.5px; + --depth: 1; + --noise: 0; +} + +/* Add variants based on LiveView classes */ +@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &); +@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &); +@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &); + +/* Use the data attribute for dark mode */ +@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); + +/* Make LiveView wrapper divs transparent for layout */ +[data-phx-session], [data-phx-teleported-src] { display: contents } + +/* This file is for your main application CSS */ diff --git a/apps/control_plane/assets/js/app.js b/apps/control_plane/assets/js/app.js new file mode 100644 index 00000000..f1a05c67 --- /dev/null +++ b/apps/control_plane/assets/js/app.js @@ -0,0 +1,82 @@ +// If you want to use Phoenix channels, run `mix help phx.gen.channel` +// to get started and then uncomment the line below. +// import "./user_socket.js" + +// You can include dependencies in two ways. +// +// The simplest option is to put them in assets/vendor and +// import them using relative paths: +// +// import "../vendor/some-package.js" +// +// Alternatively, you can `npm install some-package --prefix assets` and import +// them using a path starting with the package name: +// +// import "some-package" +// +// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file. +// To load it, simply add a second `` to your `root.html.heex` file. + +// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. +import "phoenix_html" +// Establish Phoenix Socket and LiveView configuration. +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import {hooks as colocatedHooks} from "phoenix-colocated/workharbor" +import topbar from "../vendor/topbar" + +const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +const liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 2500, + params: {_csrf_token: csrfToken}, + hooks: {...colocatedHooks}, +}) + +// Show progress bar on live navigation and form submits +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) + +// connect if there are any LiveViews on the page +liveSocket.connect() + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket + +// The lines below enable quality of life phoenix_live_reload +// development features: +// +// 1. stream server logs to the browser console +// 2. click on elements to jump to their definitions in your code editor +// +if (process.env.NODE_ENV === "development") { + window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => { + // Enable server log streaming to client. + // Disable with reloader.disableServerLogs() + reloader.enableServerLogs() + + // Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component + // + // * click with "c" key pressed to open at caller location + // * click with "d" key pressed to open at function component definition location + let keyDown + window.addEventListener("keydown", e => keyDown = e.key) + window.addEventListener("keyup", _e => keyDown = null) + window.addEventListener("click", e => { + if(keyDown === "c"){ + e.preventDefault() + e.stopImmediatePropagation() + reloader.openEditorAtCaller(e.target) + } else if(keyDown === "d"){ + e.preventDefault() + e.stopImmediatePropagation() + reloader.openEditorAtDef(e.target) + } + }, true) + + window.liveReloader = reloader + }) +} diff --git a/apps/control_plane/assets/tsconfig.json b/apps/control_plane/assets/tsconfig.json new file mode 100644 index 00000000..4910bec8 --- /dev/null +++ b/apps/control_plane/assets/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "*": ["../deps/*"] + }, + "allowJs": true, + "noEmit": true + }, + "include": ["js/**/*"] +} diff --git a/apps/control_plane/assets/vendor/heroicons.js b/apps/control_plane/assets/vendor/heroicons.js new file mode 100644 index 00000000..296f80e4 --- /dev/null +++ b/apps/control_plane/assets/vendor/heroicons.js @@ -0,0 +1,43 @@ +const plugin = require("tailwindcss/plugin") +const fs = require("fs") +const path = require("path") + +module.exports = plugin(function({matchComponents, theme}) { + let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized") + let values = {} + let icons = [ + ["", "/24/outline"], + ["-solid", "/24/solid"], + ["-mini", "/20/solid"], + ["-micro", "/16/solid"] + ] + icons.forEach(([suffix, dir]) => { + fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { + let name = path.basename(file, ".svg") + suffix + values[name] = {name, fullPath: path.join(iconsDir, dir, file)} + }) + }) + matchComponents({ + "hero": ({name, fullPath}) => { + let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") + content = encodeURIComponent(content) + let size = theme("spacing.6") + if (name.endsWith("-mini")) { + size = theme("spacing.5") + } else if (name.endsWith("-micro")) { + size = theme("spacing.4") + } + return { + [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, + "-webkit-mask": `var(--hero-${name})`, + "mask": `var(--hero-${name})`, + "mask-repeat": "no-repeat", + "background-color": "currentColor", + "vertical-align": "middle", + "display": "inline-block", + "width": size, + "height": size + } + } + }, {values}) +}) diff --git a/apps/control_plane/assets/vendor/topbar.js b/apps/control_plane/assets/vendor/topbar.js new file mode 100644 index 00000000..0552337c --- /dev/null +++ b/apps/control_plane/assets/vendor/topbar.js @@ -0,0 +1,138 @@ +/** + * @license MIT + * topbar 3.0.0 + * http://buunguyen.github.io/topbar + * Copyright (c) 2024 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + var canvas, + currentProgress, + showing, + progressTimerId = null, + fadeTimerId = null, + delayTimerId = null, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { + 0: "rgba(26, 188, 156, .9)", + ".25": "rgba(52, 152, 219, .9)", + ".50": "rgba(241, 196, 15, .9)", + ".75": "rgba(230, 126, 34, .9)", + "1.0": "rgba(211, 84, 0, .9)", + }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; // need space for shadow + + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function (delay) { + if (showing) return; + if (delay) { + if (delayTimerId) return; + delayTimerId = setTimeout(() => topbar.show(), delay); + } else { + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + if (!canvas.parentElement) document.body.appendChild(canvas); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + clearTimeout(delayTimerId); + delayTimerId = null; + if (!showing) return; + showing = false; + if (progressTimerId != null) { + window.cancelAnimationFrame(progressTimerId); + progressTimerId = null; + } + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/apps/control_plane/config/config.exs b/apps/control_plane/config/config.exs new file mode 100644 index 00000000..0aa8ec58 --- /dev/null +++ b/apps/control_plane/config/config.exs @@ -0,0 +1,75 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +import Config + +config :workharbor, + namespace: WorkHarbor, + ecto_repos: [WorkHarbor.Repo], + generators: [timestamp_type: :utc_datetime, binary_id: true] + +# T005 metadata durability; no effect consumer or live server is enabled here. +config :workharbor, WorkHarbor.Repo, + journal_mode: :wal, + synchronous: :full, + foreign_keys: :on, + busy_timeout: 2000, + log: false + +# Configure the endpoint +config :workharbor, WorkHarborWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [html: WorkHarborWeb.ErrorHTML, json: WorkHarborWeb.ErrorJSON], + layout: false + ], + pubsub_server: WorkHarbor.PubSub, + live_view: [signing_salt: "lSjF4/WT"] + +# Configure LiveView +config :phoenix_live_view, + # the attribute set on all root tags. Used for Phoenix.LiveView.ColocatedCSS. + root_tag_attribute: "phx-r" + +# Configure esbuild (the version is required) +config :esbuild, + version: "0.25.4", + workharbor: [ + args: + ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]} + ] + +# Configure tailwind (the version is required) +config :tailwind, + version: "4.3.3", + workharbor: [ + args: ~w( + --input=assets/css/app.css + --output=priv/static/assets/css/app.css + ), + cd: Path.expand("..", __DIR__), + env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]} + ] + +# Configure Elixir's Logger +config :logger, :default_formatter, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +config :jason, :decoding_integer_digit_limit, 4301 +config :phoenix, :filter_parameters, {:keep, []} +config :workharbor, :owner_http, authorities: [{"localhost", 443}], origin: "https://localhost" +config :workharbor, WorkHarborWeb.Endpoint, debug_errors: false, code_reloader: false +import_config "#{config_env()}.exs" diff --git a/apps/control_plane/config/dev.exs b/apps/control_plane/config/dev.exs new file mode 100644 index 00000000..aa441583 --- /dev/null +++ b/apps/control_plane/config/dev.exs @@ -0,0 +1,68 @@ +import Config + +# Configure your database +config :workharbor, WorkHarbor.Repo, + database: Path.expand("../workharbor_dev.db", __DIR__), + pool_size: 5, + stacktrace: true, + show_sensitive_data_on_connection_error: false + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we can use it +# to bundle .js and .css sources. +config :workharbor, WorkHarborWeb.Endpoint, + # Binding to loopback ipv4 address prevents access from other machines. + # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. + http: false, + check_origin: ["https://localhost"], + code_reloader: false, + debug_errors: false, + secret_key_base: "9kwSZZ3zqgPLCQHHHC7Q9KYXXSVttc/mmuUT4ZmFiV1BGre/cOXzP7jNrsMeH4N4", + watchers: [ + esbuild: {Esbuild, :install_and_run, [:workharbor, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:workharbor, ~w(--watch)]} + ] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Do not include metadata nor timestamps in development logs +config :logger, :default_formatter, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime + +config :phoenix_live_view, + # Include debug annotations and locations in rendered markup. + # Changing this configuration will require mix clean and a full recompile. + debug_heex_annotations: true, + debug_attributes: true, + # Enable helpful, but potentially expensive runtime checks + enable_expensive_runtime_checks: true diff --git a/apps/control_plane/config/prod.exs b/apps/control_plane/config/prod.exs new file mode 100644 index 00000000..92d6d006 --- /dev/null +++ b/apps/control_plane/config/prod.exs @@ -0,0 +1,2 @@ +import Config +config :logger, level: :info diff --git a/apps/control_plane/config/runtime.exs b/apps/control_plane/config/runtime.exs new file mode 100644 index 00000000..94c79eae --- /dev/null +++ b/apps/control_plane/config/runtime.exs @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: EUPL-1.2 +import Config + +# No listener, demo-secret fallback, HTTP redirect or forwarded-header trust. +# Explicit activation remains an operator decision outside development checks. +config :workharbor, WorkHarborWeb.Endpoint, server: false, http: false + +if config_env() == :prod do + database = System.get_env("DATABASE_PATH") || raise "DATABASE_PATH is required" + config :workharbor, WorkHarbor.Repo, database: database, pool_size: 5 +end + +if System.get_env("PHX_SERVER") == "true" do + required = fn name -> + System.get_env(name) || raise "unsafe authentication server configuration" + end + + origin = required.("WORKHARBOR_ORIGIN") + uri = URI.parse(origin) + {:ok, bind} = :inet.parse_address(String.to_charlist(required.("WORKHARBOR_BIND"))) + + config :workharbor, :owner_http, + origin: origin, + authorities: [{String.downcase(uri.host), uri.port}] + + config :workharbor, WorkHarborWeb.Endpoint, + server: true, + http: false, + debug_errors: false, + code_reloader: false, + watchers: [], + secret_key_base: required.("SECRET_KEY_BASE"), + url: [scheme: "https", host: uri.host, port: uri.port], + https: [ + ip: bind, + port: uri.port, + certfile: required.("WORKHARBOR_TLS_CERT_FILE"), + keyfile: required.("WORKHARBOR_TLS_KEY_FILE"), + # Temporary GHSA-69m6-wcv4-rwcf mitigation; see transport-security contract. + http_1_options: [enabled: true], + http_2_options: [enabled: false] + ] +end + +# The issuer must already exist; initialization is a separate explicit command. +if issuer_dir = System.get_env("WORKHARBOR_ISSUER_DIR") do + config :workharbor, :host_identity, issuer_dir: issuer_dir +end diff --git a/apps/control_plane/config/test.exs b/apps/control_plane/config/test.exs new file mode 100644 index 00000000..fad61cec --- /dev/null +++ b/apps/control_plane/config/test.exs @@ -0,0 +1,32 @@ +import Config + +# Configure your database +# +# The MIX_TEST_PARTITION environment variable can be used +# to provide built-in test partitioning in CI environment. +# Run `mix help test` for more information. +config :workharbor, WorkHarbor.Repo, + database: Path.expand("../workharbor_test.db", __DIR__), + pool_size: 5, + pool: Ecto.Adapters.SQL.Sandbox + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :workharbor, WorkHarborWeb.Endpoint, + http: false, + secret_key_base: "4XBsLMaWd4lPmLPEtg7KgIs8803lJErmCoNVLqsywrCNiiRCgTgMU4U5WdOI+TAl", + server: false + +# Print only warnings and errors during test +config :logger, level: :warning + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime + +# Enable helpful, but potentially expensive runtime checks +config :phoenix_live_view, + enable_expensive_runtime_checks: true + +# Sort query params output of verified routes for robust url comparisons +config :phoenix, + sort_verified_routes_query_params: true diff --git a/apps/control_plane/lib/mix/tasks/workharbor.hosts.init.ex b/apps/control_plane/lib/mix/tasks/workharbor.hosts.init.ex new file mode 100644 index 00000000..f878ee8d --- /dev/null +++ b/apps/control_plane/lib/mix/tasks/workharbor.hosts.init.ex @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule Mix.Tasks.Workharbor.Hosts.Init do + @shortdoc "Explicitly initialize the private client certificate issuer" + use Mix.Task + @impl true + def run(["--issuer-dir", path]) do + Mix.Task.run("app.config") + {:ok, _} = Application.ensure_all_started(:public_key) + + case WorkHarbor.Hosts.Issuer.initialize(path) do + {:ok, _} -> Mix.shell().info("Client issuer initialized") + _ -> Mix.raise("Client issuer initialization refused") + end + end + + def run(_), do: Mix.raise("Usage: mix workharbor.hosts.init --issuer-dir PATH") +end diff --git a/apps/control_plane/lib/workharbor.ex b/apps/control_plane/lib/workharbor.ex new file mode 100644 index 00000000..b403ce32 --- /dev/null +++ b/apps/control_plane/lib/workharbor.ex @@ -0,0 +1,9 @@ +defmodule WorkHarbor do + @moduledoc """ + WorkHarbor keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/apps/control_plane/lib/workharbor/accounts.ex b/apps/control_plane/lib/workharbor/accounts.ex new file mode 100644 index 00000000..bbba2490 --- /dev/null +++ b/apps/control_plane/lib/workharbor/accounts.ex @@ -0,0 +1,311 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Accounts.Authenticator do + use Ecto.Schema + @primary_key {:id, :integer, autogenerate: false} + schema "owner_authenticators" do + field :owner_id, :binary_id + field :secret_hash, :binary, redact: true + end +end + +defmodule WorkHarbor.Accounts.Credential do + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + schema "owner_credentials" do + field :owner_id, :binary_id + field :token_hash, :binary, redact: true + field :kind, :string + field :issued_at, :integer + field :expires_at, :integer + field :revoked_at, :integer + end +end + +defmodule WorkHarbor.Accounts.Principal do + @moduledoc "Trusted server-created identity, separate from request bodies." + @enforce_keys [:owner_id, :credential_id, :kind, :scopes, :issued_at, :expires_at] + defstruct @enforce_keys +end + +defmodule WorkHarbor.Accounts do + @moduledoc "Single-owner primary authentication and revocable opaque credentials." + alias WorkHarbor.Repo + alias WorkHarbor.Foundation.{Owner, AuditEvent, Request} + alias WorkHarbor.Accounts.{Authenticator, Credential, Principal} + @kinds ["cli_owner", "owner_browser", "dashboard"] + @writes [ + :logout, + :revoke, + :issue_dashboard, + :primary_login, + :host_enrollment_issue, + :host_revoke, + :config_approve + ] + @reads [:status, :owner_page, :dashboard_page, :credential_list, :host_status, :config_read] + def now, do: Application.get_env(:workharbor, :clock, fn -> System.system_time(:second) end).() + + def bootstrap(deliver, owner_id \\ nil) when is_function(deliver, 1) do + result = + transaction(fn -> + if Repo.get(Authenticator, 1) do + {:error, :already_initialized} + else + owner = + if is_nil(owner_id), + do: Repo.insert!(%Owner{display_name: "Owner"}, log: false), + else: if(Request.uuid?(owner_id), do: Repo.get(Owner, owner_id)) + + if is_nil(owner) do + {:error, :invalid_request} + else + if is_nil(owner_id), + do: + audit( + owner.id, + nil, + "owner_created", + "owner_metadata", + "internal", + "recorded", + "ok" + ) + + secret = random() + + Repo.insert!( + %Authenticator{id: 1, owner_id: owner.id, secret_hash: digest(:primary, secret)}, + log: false + ) + + audit(owner.id, nil, "owner_bootstrapped", "bootstrap", "internal", "recorded", "ok") + {:ok, owner, secret} + end + end + end) + + case result do + {:ok, owner, secret} -> + try do + if deliver.(secret) == :ok, do: {:ok, owner}, else: {:error, :delivery_failed} + rescue + _ -> {:error, :delivery_failed} + catch + _, _ -> {:error, :delivery_failed} + end + + other -> + other + end + end + + def login(owner, secret, kind) when kind in @kinds do + verifier = if Request.uuid?(owner), do: Repo.get_by(Authenticator, owner_id: owner) + expected = if verifier, do: verifier.secret_hash, else: <<0::256>> + candidate = digest(:primary, if(is_binary(secret), do: secret, else: "")) + valid = Plug.Crypto.secure_compare(expected, candidate) + + if verifier && valid && canonical?(secret, 43) do + transaction(fn -> issue!(owner, kind) end) + else + {:error, :unauthenticated} + end + end + + def login(_, _, _), do: {:error, :invalid_request} + + def authenticate(token, expected_kind \\ :any) do + with true <- token?(token), + %Credential{} = c <- Repo.get_by(Credential, token_hash: digest(:credential, token)), + true <- active?(c) and (expected_kind == :any or c.kind == expected_kind) do + {:ok, principal(c)} + else + _ -> {:error, :unauthenticated} + end + end + + def authorize(p, action, owner \\ nil) + + def authorize(%Principal{} = p, action, owner) do + current = Repo.get(Credential, p.credential_id) + owner = owner || p.owner_id + + valid = + current && active?(current) && current.owner_id == p.owner_id && current.kind == p.kind + + allowed = valid && owner == p.owner_id && action_allowed?(current.kind, action) + + if allowed do + :ok + else + if current && current.owner_id == p.owner_id, + do: + audit( + p.owner_id, + p.credential_id, + "authorization_denied", + safe_action(action), + channel(p.kind), + "denied", + "forbidden" + ) + + {:error, :forbidden} + end + end + + def authorize(_, _, _), do: {:error, :forbidden} + + def deny(%Principal{} = p, action) do + audit( + p.owner_id, + p.credential_id, + "authorization_denied", + safe_action(action), + channel(p.kind), + "denied", + "forbidden" + ) + + {:error, :forbidden} + end + + def issue_dashboard(p) do + transaction(fn -> + with :ok <- authorize(p, :issue_dashboard), do: issue!(p.owner_id, "dashboard") + end) + end + + def revoke(p, id) do + transaction(fn -> + with :ok <- authorize(p, :revoke), + true <- Request.uuid?(id), + %Credential{} = c <- Repo.get_by(Credential, id: id, owner_id: p.owner_id) do + if is_nil(c.revoked_at), + do: c |> Ecto.Changeset.change(revoked_at: now()) |> Repo.update!(log: false) + + audit( + p.owner_id, + nil, + "credential_revoked", + "revoke", + channel(p.kind), + "recorded", + "ok", + %{"actor_credential_id" => p.credential_id, "target_credential_id" => c.id} + ) + + :ok + else + {:error, _} = e -> + e + + _ -> + audit( + p.owner_id, + p.credential_id, + "authorization_denied", + "revoke", + channel(p.kind), + "denied", + "not_found" + ) + + {:error, :not_found} + end + end) + end + + def status(%Principal{} = p), do: Map.from_struct(p) + def token?(<<"whr1_", encoded::binary>>), do: canonical?(encoded, 43) + def token?(_), do: false + def primary?(secret), do: canonical?(secret, 43) + + defp canonical?(value, size) when is_binary(value) and byte_size(value) == size do + case Base.url_decode64(value, padding: false) do + {:ok, raw} -> byte_size(raw) == 32 and Base.url_encode64(raw, padding: false) == value + _ -> false + end + end + + defp canonical?(_, _), do: false + + defp issue!(owner, kind) do + token = "whr1_" <> random() + time = now() + + c = + Repo.insert!( + %Credential{ + owner_id: owner, + token_hash: digest(:credential, token), + kind: kind, + issued_at: time, + expires_at: time + if(kind == "cli_owner", do: 2_592_000, else: 28_800) + }, + log: false + ) + + audit(owner, c.id, "credential_issued", "issue", channel(kind), "recorded", "ok") + {:ok, token, principal(c)} + end + + defp active?(c), do: is_nil(c.revoked_at) and c.expires_at > now() and c.issued_at <= now() + + defp principal(c), + do: %Principal{ + owner_id: c.owner_id, + credential_id: c.id, + kind: c.kind, + scopes: + if(c.kind == "dashboard", do: ["dashboard:read"], else: ["owner:read", "owner:write"]), + issued_at: c.issued_at, + expires_at: c.expires_at + } + + defp action_allowed?(kind, a) when a in @writes, do: kind in ["cli_owner", "owner_browser"] + defp action_allowed?(kind, :owner_page), do: kind == "owner_browser" + defp action_allowed?(kind, :dashboard_page), do: kind == "dashboard" + defp action_allowed?(kind, :host_status), do: kind in ["cli_owner", "owner_browser"] + defp action_allowed?(_, a) when a in [:status, :credential_list, :config_read], do: true + defp action_allowed?(_, _), do: false + defp safe_action(a) when a in @writes or a in @reads, do: Atom.to_string(a) + defp safe_action(_), do: "unknown" + defp channel("cli_owner"), do: "cli" + defp channel(_), do: "browser" + + defp audit(owner, credential, event, action, channel, outcome, code, extra \\ %{}) do + Repo.insert!( + %AuditEvent{ + owner_id: owner, + event: event, + action: action, + channel: channel, + outcome: outcome, + code: code, + metadata: + Map.merge(if(credential, do: %{"credential_id" => credential}, else: %{}), extra) + }, + log: false + ) + end + + defp digest(:primary, v), do: :crypto.hash(:sha256, "WorkHarbor primary v1\0" <> v) + defp digest(:credential, v), do: :crypto.hash(:sha256, "WorkHarbor credential v1\0" <> v) + defp random, do: :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + + defp transaction(work) do + if Repo.in_transaction?() do + {:error, :nested_transaction} + else + case Repo.transaction(work, mode: :immediate, log: false) do + {:ok, result} -> result + {:error, _} -> {:error, :persistence_failed} + end + end + rescue + _ in [Exqlite.Error, DBConnection.ConnectionError, Ecto.ConstraintError] -> + {:error, :persistence_failed} + end +end diff --git a/apps/control_plane/lib/workharbor/application.ex b/apps/control_plane/lib/workharbor/application.ex new file mode 100644 index 00000000..414ad0d6 --- /dev/null +++ b/apps/control_plane/lib/workharbor/application.ex @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Application do + # See https://elixir.hexdocs.pm/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + identity = Application.get_env(:workharbor, :host_identity) + + {:ok, endpoint} = + WorkHarborWeb.RuntimeSecurity.identity_config( + Application.fetch_env!(:workharbor, WorkHarborWeb.Endpoint), + identity + ) + + Application.put_env(:workharbor, WorkHarborWeb.Endpoint, endpoint) + + WorkHarborWeb.RuntimeSecurity.validate!( + endpoint, + Application.fetch_env!(:workharbor, :owner_http) + ) + + if identity, do: :ok = WorkHarborWeb.AgentLogFilter.install() + + children = [ + WorkHarborWeb.Telemetry, + WorkHarbor.Repo, + {Ecto.Migrator, + repos: Application.fetch_env!(:workharbor, :ecto_repos), skip: skip_migrations?()}, + {Phoenix.PubSub, name: WorkHarbor.PubSub}, + # Liveness is initialized before the listener when host identity is configured. + # Start a worker by calling: WorkHarbor.Worker.start_link(arg) + # {WorkHarbor.Worker, arg}, + # Start to serve requests, typically the last entry + WorkHarborWeb.Endpoint + ] + + children = + if identity, do: List.insert_at(children, -2, WorkHarbor.HostConnections), else: children + + # See https://elixir.hexdocs.pm/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: WorkHarbor.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + WorkHarborWeb.Endpoint.config_change(changed, removed) + :ok + end + + defp skip_migrations?() do + # By default, sqlite migrations are run when using a release + System.get_env("RELEASE_NAME") == nil + end +end diff --git a/apps/control_plane/lib/workharbor/config_trust.ex b/apps/control_plane/lib/workharbor/config_trust.ex new file mode 100644 index 00000000..e44ac673 --- /dev/null +++ b/apps/control_plane/lib/workharbor/config_trust.ex @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.ConfigTrust.Approval do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "revision_trust_approvals" do + field :owner_id, :binary_id + field :descriptor, :map, redact: true + field :descriptor_bytes, :binary, redact: true + field :descriptor_sha256, :string + field :approved_at, :integer + timestamps() + end +end + +defmodule WorkHarbor.ConfigTrust do + @moduledoc "Native exact-revision approval; no HTTP, dispatch or provider effects." + alias WorkHarbor.{Accounts, Repo, Hosts} + alias WorkHarbor.Accounts.Principal + alias WorkHarbor.Foundation.{Request, Canonical, AuditEvent} + alias __MODULE__.Approval + + def approve(%Principal{} = principal, descriptor) do + with :ok <- Accounts.authorize(principal, :config_approve), + {:ok, normalized, bytes, digest} <- normalize(descriptor) do + transaction(fn -> + authorize!(principal, :config_approve) + + case Repo.get_by(Approval, [owner_id: principal.owner_id, descriptor_sha256: digest], + log: false + ) do + nil -> + approval = + Repo.insert!( + %Approval{ + owner_id: principal.owner_id, + descriptor: normalized, + descriptor_bytes: bytes, + descriptor_sha256: digest, + approved_at: Accounts.now() + }, + log: false + ) + + Repo.insert!( + %AuditEvent{ + owner_id: principal.owner_id, + event: "revision_approved", + action: "config_approve", + channel: channel(principal), + outcome: "recorded", + code: "ok", + metadata: %{"approval_id" => approval.id} + }, + log: false + ) + + {:ok, approval} + + approval -> + if approval.descriptor_bytes == bytes and approval.descriptor == normalized, + do: {:ok, approval}, + else: Repo.rollback(:trust_conflict) + end + end) + end + end + + def approve(_, _), do: {:error, :forbidden} + + def lookup(%Principal{} = principal, reference, descriptor) do + with :ok <- Accounts.authorize(principal, :config_read) do + lookup_owner(principal.owner_id, reference, descriptor) + end + end + + def lookup(_, _, _), do: {:error, :forbidden} + + def lookup_for_host(%Hosts.Peer{} = peer, reference, descriptor) do + if Hosts.current_peer?(peer), + do: lookup_owner(peer.owner_id, reference, descriptor), + else: {:error, :forbidden} + end + + def lookup_for_host(_, _, _), do: {:error, :forbidden} + + defp lookup_owner(owner, reference, descriptor) do + with true <- Request.uuid?(reference), + {:ok, normalized, bytes, digest} <- normalize(descriptor), + %Approval{} = row <- Repo.get_by(Approval, [id: reference, owner_id: owner], log: false), + true <- + row.descriptor_sha256 == digest and row.descriptor_bytes == bytes and + row.descriptor == normalized do + {:ok, row} + else + _ -> {:error, :trust_required} + end + end + + defp normalize(descriptor) do + with {:ok, normalized} <- Request.normalize_config(descriptor), + {:ok, bytes} <- Canonical.bytes(normalized) do + {:ok, normalized, bytes, Base.encode16(:crypto.hash(:sha256, bytes), case: :lower)} + else + _ -> {:error, :invalid_configuration} + end + end + + defp authorize!(principal, action) do + if Accounts.authorize(principal, action) != :ok, do: Repo.rollback(:forbidden) + end + + defp channel(%Principal{kind: "owner_browser"}), do: "browser" + defp channel(_), do: "cli" + + defp transaction(fun) do + if Repo.in_transaction?() do + {:error, :nested_transaction} + else + case Repo.transaction(fun, mode: :immediate) do + {:ok, value} -> value + {:error, code} -> {:error, code} + end + end + rescue + _ in [Exqlite.Error, DBConnection.ConnectionError, Ecto.ConstraintError] -> + {:error, :persistence_failed} + end +end diff --git a/apps/control_plane/lib/workharbor/foundation.ex b/apps/control_plane/lib/workharbor/foundation.ex new file mode 100644 index 00000000..a4145d0c --- /dev/null +++ b/apps/control_plane/lib/workharbor/foundation.ex @@ -0,0 +1,637 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Foundation do + @moduledoc """ + Owner-scoped persistence only. No authentication, provider calls or effect consumer. + Mutations require a top-level transaction so returned acceptance is committed. + Owner context must be authenticated by the later API layer, never by request data. + """ + import Ecto.Query + alias WorkHarbor.Repo + + alias WorkHarbor.Foundation.{ + Owner, + Host, + Workspace, + Operation, + PersistentStorage, + RuntimeInstance, + RuntimeBinding, + AuditEvent, + Request, + Canonical + } + + @channels ["cli", "browser", "internal"] + @terminal ["succeeded", "failed"] + @counter_max 9_223_372_036_854_775_807 + + def create_owner(display_name) when is_binary(display_name) do + if String.valid?(display_name) and display_name != "" do + transaction(fn -> + owner = Repo.insert!(%Owner{display_name: display_name}) + + Repo.insert!(%AuditEvent{ + owner_id: owner.id, + event: "owner_created", + action: "owner_metadata", + channel: "internal", + outcome: "recorded" + }) + + owner + end) + else + {:error, :invalid_request} + end + end + + def create_owner(_), do: {:error, :invalid_request} + + def create_host(owner_id) do + transaction(fn -> + owner!(owner_id) + host = Repo.insert!(%Host{owner_id: owner_id}) + + Repo.insert!(%AuditEvent{ + owner_id: owner_id, + host_id: host.id, + event: "host_recorded", + action: "host_metadata", + channel: "internal", + outcome: "recorded" + }) + + host + end) + end + + def accept(owner_id, key, request, channel) do + with true <- channel in @channels, + {:ok, normalized} <- Request.normalize(owner_id, key, request) do + transaction(fn -> + owner!(owner_id) + identity = normalized.request + + existing = + Repo.get_by(Operation, + owner_id: owner_id, + workspace_id: identity["workspace_id"], + idempotency_key: key + ) + + if existing do + if existing.request_bytes == normalized.bytes and + existing.request_hash == normalized.hash, + do: {:replayed, existing}, + else: Repo.rollback(:idempotency_conflict) + else + accept_new!(owner_id, key, normalized, channel) + end + end) + else + false -> {:error, :invalid_channel} + error -> error + end + end + + def lookup(owner_id, workspace_id, key) do + with true <- Request.uuid?(owner_id) and Request.uuid?(workspace_id) and Request.key?(key), + %Operation{} = operation <- + Repo.get_by(Operation, + owner_id: owner_id, + workspace_id: workspace_id, + idempotency_key: key + ) do + {:ok, operation} + else + _ -> {:error, :not_found} + end + end + + def bindings(owner_id, host_id, operation_id) do + with true <- Enum.all?([owner_id, host_id, operation_id], &Request.uuid?/1), + {operation, _workspace} <- scoped_operation(owner_id, host_id, operation_id) do + bindings!(operation.id) + else + _ -> {:error, :not_found} + end + end + + @doc "Fenced native storage scope and immutable ORIGINAL seed binding; no provider effects." + def storage_binding(owner, host, operation, intent, storage) do + transaction(fn -> + {op, workspace} = scoped_operation!(owner, host, operation) + current_intent!(op, workspace, intent) + storage_binding!(op, workspace, storage) + end) + end + + @doc "Record the first completed provider handle without replacing retained storage." + def observe_storage(owner, host, operation, intent, storage, handle) do + if is_binary(handle) and String.valid?(handle) and handle != "" and + not String.contains?(handle, <<0>>) do + transaction(fn -> + {op, workspace} = scoped_operation!(owner, host, operation) + current_intent!(op, workspace, intent) + if op.status != "executing", do: Repo.rollback(:invalid_transition) + binding = storage_binding!(op, workspace, storage) + + case binding.provider_handle do + nil -> + {count, _} = + Repo.update_all( + from(s in PersistentStorage, + where: s.id == ^storage and is_nil(s.provider_handle) + ), + set: [provider_handle: handle, updated_at: now()] + ) + + if count != 1, do: Repo.rollback(:storage_mismatch) + + audit!(op, workspace, "storage_observed", "internal", "recorded", + storage_ids: [storage] + ) + + %{binding | provider_handle: handle} + + ^handle -> + binding + + _ -> + Repo.rollback(:storage_mismatch) + end + end) + else + {:error, :invalid_request} + end + end + + defp storage_binding!(operation, workspace, id) do + if operation.action not in ["create", "rebuild"], do: Repo.rollback(:invalid_action) + if operation.status not in ["queued", "executing"], do: Repo.rollback(:invalid_transition) + + storage = + if Request.uuid?(id), + do: + Repo.get_by(PersistentStorage, + id: id, + workspace_id: workspace.id, + host_id: workspace.host_id + ) + + if is_nil(storage) or not is_nil(storage.deleted_at), do: Repo.rollback(:storage_unavailable) + + original = + Repo.get_by(Operation, + id: storage.reservation_operation_id, + owner_id: operation.owner_id, + workspace_id: workspace.id, + action: "create" + ) + + if is_nil(original), do: Repo.rollback(:storage_mismatch) + + %{ + storage_id: storage.id, + owner_id: operation.owner_id, + host_id: workspace.host_id, + workspace_id: workspace.id, + reservation_operation_id: original.id, + initial_config: original.execution_config_snapshot, + provider_handle: storage.provider_handle + } + end + + def claim(owner_id, host_id, operation_id, intent_version) do + transaction(fn -> + {operation, workspace} = scoped_operation!(owner_id, host_id, operation_id) + current_intent!(operation, workspace, intent_version) + + case operation.status do + "queued" -> + updated = update_operation!(operation, status: "executing") + audit!(updated, workspace, "operation_claimed", "internal", "executing") + {:claimed, updated} + + "executing" -> + {:already_executing, operation} + + _ -> + Repo.rollback(:invalid_transition) + end + end) + end + + def reserve_candidate(owner_id, host_id, operation_id, intent_version) do + transaction(fn -> + {operation, workspace} = scoped_operation!(owner_id, host_id, operation_id) + current_intent!(operation, workspace, intent_version) + if operation.status not in ["queued", "executing"], do: Repo.rollback(:invalid_transition) + if operation.action not in ["create", "rebuild"], do: Repo.rollback(:invalid_action) + candidate!(operation, workspace) + end) + end + + def finish( + owner_id, + host_id, + operation_id, + intent_version, + expected_status, + expected_bindings, + outcome, + observation \\ nil + ) do + with {:ok, result} <- terminal_result(outcome, observation) do + transaction(fn -> + {operation, workspace} = scoped_operation!(owner_id, host_id, operation_id) + if operation.intent_version !== intent_version, do: Repo.rollback(:stale_intent) + actual = bindings!(operation.id) + if actual !== expected_bindings, do: Repo.rollback(:runtime_mismatch) + + if operation.status in @terminal do + if operation.terminal_result === result, + do: {:replayed, operation}, + else: Repo.rollback(:result_conflict) + else + current_intent!(operation, workspace, intent_version) + if operation.status != expected_status, do: Repo.rollback(:invalid_transition) + status = result["outcome"]["status"] + + if operation.status not in ["queued", "executing"] or + (status == "succeeded" and operation.status != "executing"), + do: Repo.rollback(:invalid_transition) + + observe_terminal!(operation, actual, result) + updated = update_operation!(operation, status: status, terminal_result: result) + + audit!(updated, workspace, "operation_terminal", "internal", status, + code: result["outcome"]["code"], + observation: result["observation"] + ) + + {:finished, updated} + end + end) + end + end + + defp accept_new!(owner_id, key, normalized, channel) do + request = normalized.request + parameters = request["parameters"] + action = request["action"] + workspace = Repo.get(Workspace, request["workspace_id"]) + if workspace && workspace.owner_id != owner_id, do: Repo.rollback(:not_found) + + if workspace && + Repo.exists?( + from o in Operation, + where: o.workspace_id == ^workspace.id and o.status in ["queued", "executing"] + ), + do: Repo.rollback(:workspace_busy) + + if action == "create" do + if workspace, do: Repo.rollback(:workspace_exists) + host = Repo.get_by(Host, id: parameters["host_id"], owner_id: owner_id) + if is_nil(host), do: Repo.rollback(:not_found) + else + if is_nil(workspace), do: Repo.rollback(:not_found) + end + + target = + if workspace, + do: Repo.get_by(RuntimeInstance, workspace_id: workspace.id, is_current: true), + else: nil + + if action == "start" and (is_nil(target) or is_nil(target.effective_config)), + do: Repo.rollback(:runtime_unavailable) + + if action == "rebuild" and + not Repo.exists?( + from s in PersistentStorage, + where: s.workspace_id == ^workspace.id and is_nil(s.deleted_at) + ), + do: Repo.rollback(:storage_unavailable) + + desired = if action in ["stop", "remove_runtime"], do: "stopped", else: "running" + config = parameters["config"] || (workspace && workspace.requested_config) + + workspace = + if workspace do + if workspace.intent_version >= @counter_max, do: Repo.rollback(:counter_exhausted) + + {count, _} = + Repo.update_all( + from(w in Workspace, + where: w.id == ^workspace.id and w.intent_version == ^workspace.intent_version + ), + set: [ + intent_version: workspace.intent_version + 1, + desired_state: desired, + requested_config: config, + updated_at: now() + ] + ) + + if count != 1, do: Repo.rollback(:stale_intent) + Repo.get!(Workspace, workspace.id) + else + Repo.insert!(%Workspace{ + id: request["workspace_id"], + owner_id: owner_id, + host_id: parameters["host_id"], + desired_state: desired, + intent_version: 1, + requested_config: config + }) + end + + snapshot = if action == "start", do: target.effective_config, else: parameters["config"] + + operation = + Repo.insert!(%Operation{ + owner_id: owner_id, + workspace_id: workspace.id, + idempotency_key: key, + action: action, + request_version: 1, + request_bytes: normalized.bytes, + request_hash: normalized.hash, + intent_version: workspace.intent_version, + status: "queued", + execution_config_snapshot: snapshot + }) + + storage_ids = + if action == "create" do + storage = + Repo.insert!(%PersistentStorage{ + workspace_id: workspace.id, + host_id: workspace.host_id, + reservation_operation_id: operation.id + }) + + [storage.id] + else + [] + end + + if target, do: bind!(operation, target, "target") + if action in ["create", "rebuild"], do: candidate!(operation, workspace) + + audit!(operation, workspace, "operation_accepted", channel, "queued", + storage_ids: storage_ids + ) + + {:accepted, operation} + end + + defp candidate!(operation, workspace) do + case Repo.get_by(RuntimeInstance, reservation_operation_id: operation.id) do + %RuntimeInstance{} = existing -> + binding = + Repo.get_by(RuntimeBinding, + operation_id: operation.id, + role: "candidate", + runtime_id: existing.id + ) + + if is_nil(binding), do: Repo.rollback(:runtime_mismatch) + {:replayed, existing} + + nil -> + if workspace.last_runtime_generation >= @counter_max, + do: Repo.rollback(:counter_exhausted) + + generation = workspace.last_runtime_generation + 1 + + {count, _} = + Repo.update_all( + from(w in Workspace, + where: + w.id == ^workspace.id and + w.last_runtime_generation == ^workspace.last_runtime_generation + ), + set: [last_runtime_generation: generation, updated_at: now()] + ) + + if count != 1, do: Repo.rollback(:stale_intent) + + runtime = + Repo.insert!(%RuntimeInstance{ + workspace_id: workspace.id, + host_id: workspace.host_id, + reservation_operation_id: operation.id, + generation: generation, + observed_state: "pending" + }) + + bind!(operation, runtime, "candidate") + audit!(operation, workspace, "runtime_reserved", "internal", "pending", runtime: runtime) + {:reserved, runtime} + end + end + + defp bind!(operation, runtime, role) do + Repo.insert!(%RuntimeBinding{ + operation_id: operation.id, + workspace_id: operation.workspace_id, + runtime_id: runtime.id, + role: role + }) + end + + defp bindings!(operation_id) do + rows = + Repo.all( + from b in RuntimeBinding, + join: r in RuntimeInstance, + on: r.id == b.runtime_id, + where: b.operation_id == ^operation_id, + select: {b.role, r.id, r.generation} + ) + + Enum.reduce(rows, %{"target" => nil, "candidate" => nil}, fn {role, id, generation}, acc -> + Map.put(acc, role, %{"id" => id, "generation" => generation}) + end) + end + + defp terminal_result(outcome, observation) do + allowed = [ + %{"status" => "succeeded", "code" => "ok"}, + %{"status" => "failed", "code" => "operation_failed"} + ] + + with true <- outcome in allowed, + {:ok, observation} <- observation(observation), + {:ok, _} <- Canonical.bytes(%{"outcome" => outcome, "observation" => observation}) do + {:ok, %{"outcome" => outcome, "observation" => observation}} + else + _ -> {:error, :invalid_result} + end + end + + defp observation(nil), do: {:ok, nil} + + defp observation(value) when is_map(value) and not is_struct(value) do + required = ["runtime_id", "generation", "observed_state"] + optional = ["provider_instance_id", "effective_config", "deleted"] + + valid = + Enum.all?(required, &Map.has_key?(value, &1)) and + Enum.all?(Map.keys(value), &(&1 in (required ++ optional))) and + Request.uuid?(value["runtime_id"]) and is_integer(value["generation"]) and + value["generation"] > 0 and + value["observed_state"] in [ + "pending", + "provisioning", + "running", + "stopping", + "stopped", + "failed", + "unknown" + ] and + Map.get(value, "deleted", false) in [true, false] and + (is_nil(value["provider_instance_id"]) or + (is_binary(value["provider_instance_id"]) and + String.valid?(value["provider_instance_id"]))) + + with true <- valid, + {:ok, effective} <- effective_config(value["effective_config"]) do + {:ok, + %{"provider_instance_id" => nil, "deleted" => false} + |> Map.merge(value) + |> Map.put("effective_config", effective)} + else + _ -> {:error, :invalid_result} + end + end + + defp observation(_), do: {:error, :invalid_result} + + defp effective_config(nil), do: {:ok, nil} + defp effective_config(value), do: Request.normalize_config(value) + + defp observe_terminal!(_operation, _bindings, %{"observation" => nil}), do: :ok + + defp observe_terminal!(operation, bindings, result) do + observed = result["observation"] + identity = %{"id" => observed["runtime_id"], "generation" => observed["generation"]} + role = Enum.find_value(bindings, fn {role, binding} -> if binding == identity, do: role end) + if is_nil(role), do: Repo.rollback(:runtime_mismatch) + runtime = Repo.get!(RuntimeInstance, identity["id"]) + provider = observed["provider_instance_id"] + effective = observed["effective_config"] + + if not is_nil(runtime.provider_instance_id) and not is_nil(provider) and + runtime.provider_instance_id != provider, + do: Repo.rollback(:runtime_mismatch) + + if role == "target" and not is_nil(effective) and is_nil(runtime.effective_config), + do: Repo.rollback(:runtime_mismatch) + + if not is_nil(runtime.effective_config) and not is_nil(effective) and + runtime.effective_config !== effective, + do: Repo.rollback(:runtime_mismatch) + + if not is_nil(effective) and role == "candidate" and + effective !== operation.execution_config_snapshot, + do: Repo.rollback(:runtime_mismatch) + + if operation.action == "start" and not is_nil(effective) and + effective !== operation.execution_config_snapshot, + do: Repo.rollback(:runtime_mismatch) + + deleted = observed["deleted"] + + if deleted and + (operation.action != "remove_runtime" or role != "target" or + result["outcome"]["status"] != "succeeded" or observed["observed_state"] != "stopped"), + do: Repo.rollback(:invalid_result) + + changes = [ + observed_state: observed["observed_state"], + provider_instance_id: + if(is_nil(provider), do: runtime.provider_instance_id, else: provider), + effective_config: if(is_nil(effective), do: runtime.effective_config, else: effective) + ] + + changes = if deleted, do: changes ++ [deleted_at: now(), is_current: false], else: changes + Repo.update!(Ecto.Changeset.change(runtime, changes)) + end + + defp update_operation!(operation, changes) do + {count, _} = + Repo.update_all( + from(o in Operation, where: o.id == ^operation.id and o.status == ^operation.status), + set: changes ++ [updated_at: now()] + ) + + if count != 1, do: Repo.rollback(:invalid_transition) + Repo.get!(Operation, operation.id) + end + + defp audit!(operation, workspace, event, channel, outcome, options \\ []) do + runtime = options[:runtime] + observed = options[:observation] + metadata = if options[:storage_ids], do: %{"storage_ids" => options[:storage_ids]}, else: %{} + + Repo.insert!(%AuditEvent{ + owner_id: operation.owner_id, + host_id: workspace.host_id, + workspace_id: workspace.id, + operation_id: operation.id, + event: event, + action: operation.action, + intent_version: operation.intent_version, + channel: channel, + outcome: outcome, + code: options[:code], + runtime_id: (runtime && runtime.id) || (observed && observed["runtime_id"]), + runtime_generation: (runtime && runtime.generation) || (observed && observed["generation"]), + metadata: metadata + }) + end + + defp owner!(id) do + if not Request.uuid?(id) or is_nil(Repo.get(Owner, id)), do: Repo.rollback(:not_found) + end + + defp scoped_operation(owner, host, id) do + Repo.one( + from o in Operation, + join: w in Workspace, + on: w.id == o.workspace_id, + where: + o.id == ^id and o.owner_id == ^owner and w.owner_id == ^owner and w.host_id == ^host, + select: {o, w} + ) + end + + defp scoped_operation!(owner, host, id) do + if not Enum.all?([owner, host, id], &Request.uuid?/1), do: Repo.rollback(:not_found) + scoped_operation(owner, host, id) || Repo.rollback(:not_found) + end + + defp current_intent!(operation, workspace, expected) do + if operation.intent_version !== expected or workspace.intent_version !== expected, + do: Repo.rollback(:stale_intent) + end + + defp now, do: DateTime.utc_now() + + defp transaction(work) do + if Repo.in_transaction?() do + {:error, :nested_transaction} + else + Repo.transaction(work, mode: :immediate) + end + rescue + error in [Exqlite.Error, DBConnection.ConnectionError, Ecto.ConstraintError] -> + # Never return adapter exceptions, SQL or their parameter-bearing diagnostics. + if String.contains?(Exception.message(error), ["database is locked", "database is busy"]), + do: {:error, :persistence_busy}, + else: {:error, :persistence_failed} + end +end diff --git a/apps/control_plane/lib/workharbor/foundation/canonical.ex b/apps/control_plane/lib/workharbor/foundation/canonical.ex new file mode 100644 index 00000000..3ff029b1 --- /dev/null +++ b/apps/control_plane/lib/workharbor/foundation/canonical.ex @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Foundation.Canonical do + @moduledoc "Native integer-only canonical bytes; this module does not parse raw JSON." + @integer_limit Integer.pow(10, 4300) + + def bytes(value) do + {:ok, value |> encode(0) |> IO.iodata_to_binary()} + catch + :invalid_json -> {:error, :invalid_json} + end + + defp encode(nil, _), do: "null" + defp encode(true, _), do: "true" + defp encode(false, _), do: "false" + + defp encode(value, _) + when is_integer(value) and value > -@integer_limit and value < @integer_limit, + do: Integer.to_string(value) + + defp encode(value, _) when is_binary(value) do + if not String.valid?(value), do: throw(:invalid_json) + [34, for(<>, do: escape(point)), 34] + end + + defp encode(value, depth) when is_list(value) and depth < 128, + do: [91, list(value, depth + 1), 93] + + defp encode(value, depth) when is_map(value) and not is_struct(value) and depth < 128 do + keys = Map.keys(value) + if Enum.any?(keys, &(not is_binary(&1) or not String.valid?(&1))), do: throw(:invalid_json) + # UTF-8 lexical order agrees with Unicode scalar order for valid scalar strings. + entries = + for key <- Enum.sort(keys), + do: [encode(key, depth + 1), 58, encode(Map.fetch!(value, key), depth + 1)] + + [123, Enum.intersperse(entries, 44), 125] + end + + defp encode(_, _), do: throw(:invalid_json) + defp list([], _), do: [] + defp list([value], depth), do: encode(value, depth) + defp list([value | rest], depth), do: [encode(value, depth), 44, list(rest, depth)] + defp list(_, _), do: throw(:invalid_json) + defp escape(34), do: "\\\"" + defp escape(92), do: "\\\\" + defp escape(8), do: "\\b" + defp escape(12), do: "\\f" + defp escape(10), do: "\\n" + defp escape(13), do: "\\r" + defp escape(9), do: "\\t" + + defp escape(point) when point < 32, + do: + "\\u" <> (point |> Integer.to_string(16) |> String.downcase() |> String.pad_leading(4, "0")) + + defp escape(point), do: <> +end diff --git a/apps/control_plane/lib/workharbor/foundation/records.ex b/apps/control_plane/lib/workharbor/foundation/records.ex new file mode 100644 index 00000000..89d97b20 --- /dev/null +++ b/apps/control_plane/lib/workharbor/foundation/records.ex @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Foundation.Owner do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "owners" do + field :display_name, :string + timestamps() + end +end + +defmodule WorkHarbor.Foundation.Host do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "hosts" do + field :owner_id, :binary_id + field :identity_fingerprint, :string + field :capabilities, {:array, :string}, default: [] + field :last_seen, :utc_datetime_usec + field :lease_expires_at, :utc_datetime_usec + timestamps() + end +end + +defmodule WorkHarbor.Foundation.Workspace do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "workspaces" do + field :owner_id, :binary_id + field :host_id, :binary_id + field :desired_state, :string + field :intent_version, :integer, default: 0 + field :last_runtime_generation, :integer, default: 0 + field :requested_config, :map + timestamps() + end +end + +defmodule WorkHarbor.Foundation.Operation do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "operations" do + field :owner_id, :binary_id + field :workspace_id, :binary_id + field :idempotency_key, :string + field :action, :string + field :request_version, :integer + field :request_bytes, :binary + field :request_hash, :string + field :intent_version, :integer + field :status, :string + field :execution_config_snapshot, :map + field :terminal_result, :map + timestamps() + end +end + +defmodule WorkHarbor.Foundation.PersistentStorage do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "persistent_storage" do + field :workspace_id, :binary_id + field :host_id, :binary_id + field :reservation_operation_id, :binary_id + field :provider_handle, :string + field :deleted_at, :utc_datetime_usec + timestamps() + end +end + +defmodule WorkHarbor.Foundation.RuntimeInstance do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "runtime_instances" do + field :workspace_id, :binary_id + field :host_id, :binary_id + field :reservation_operation_id, :binary_id + field :generation, :integer + field :provider_instance_id, :string + field :observed_state, :string + field :effective_config, :map + field :is_current, :boolean, default: false + field :deleted_at, :utc_datetime_usec + timestamps() + end +end + +defmodule WorkHarbor.Foundation.RuntimeBinding do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "operation_runtime_bindings" do + field :operation_id, :binary_id + field :workspace_id, :binary_id + field :runtime_id, :binary_id + field :role, :string + timestamps() + end +end + +defmodule WorkHarbor.Foundation.AuditEvent do + @moduledoc false + use Ecto.Schema + @primary_key {:id, :binary_id, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + schema "audit_events" do + field :owner_id, :binary_id + field :host_id, :binary_id + field :workspace_id, :binary_id + field :operation_id, :binary_id + field :event, :string + field :action, :string + field :intent_version, :integer + field :channel, :string + field :outcome, :string + field :code, :string + field :runtime_id, :binary_id + field :runtime_generation, :integer + field :metadata, :map, default: %{} + timestamps() + end +end diff --git a/apps/control_plane/lib/workharbor/foundation/repository_identity.ex b/apps/control_plane/lib/workharbor/foundation/repository_identity.ex new file mode 100644 index 00000000..259c7946 --- /dev/null +++ b/apps/control_plane/lib/workharbor/foundation/repository_identity.ex @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Foundation.RepositoryIdentity do + @moduledoc "Literal lexical origin identity, without fetching or remote alias equivalence." + @username ~r/\A[A-Za-z0-9._-]+\z/ + @host ~r/\A[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*\.?\z/ + @forbidden ~r/[\x00-\x20\x7f\x{0085}\x{00a0}\x{1680}\x{2000}-\x{200a}\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\\?#]/u + + def valid?(value) when is_binary(value) do + String.valid?(value) and value != "" and not Regex.match?(@forbidden, value) and + not String.contains?(Regex.replace(~r/%[0-9A-Fa-f]{2}/, value, ""), "%") and shape?(value) + end + + def valid?(_), do: false + + defp shape?(value) do + case Regex.run(~r/\A(https|ssh):\/\/([^\/]+)(\/.*)\z/is, value) do + [_, scheme, authority, _path] -> + authority?(authority, String.downcase(scheme) == "ssh", true) + + _ -> + not Regex.match?(~r/\A[A-Za-z][A-Za-z0-9+.-]*:\/\//, value) and scp?(value) + end + end + + defp scp?(value) do + case Regex.run(~r/\A((?:[A-Za-z0-9._-]+@)?(?:\[[^\]]+\]|[^:]+)):(.+)\z/s, value) do + [_, authority, _path] -> authority?(authority, true, false) + _ -> false + end + end + + defp authority?(authority, username_allowed, port_allowed) do + case String.split(authority, "@") do + [host] -> + host_port?(host, port_allowed) + + [username, host] -> + username_allowed and Regex.match?(@username, username) and host_port?(host, port_allowed) + + _ -> + false + end + end + + defp host_port?(authority, ports) do + captures = + if String.starts_with?(authority, "["), + do: Regex.run(~r/\A(\[[^\]]+\])(?::([0-9]+))?\z/, authority), + else: Regex.run(~r/\A([^:]+)(?::([0-9]+))?\z/, authority) + + case captures do + [_, host] -> host?(host) + [_, host, port] -> ports and host?(host) and port?(port) + _ -> false + end + end + + defp host?("[" <> rest) do + literal = String.trim_trailing(rest, "]") + + case :inet.parse_address(String.to_charlist(literal)) do + {:ok, tuple} when tuple_size(tuple) == 8 -> true + _ -> false + end + end + + defp host?(host), do: Regex.match?(@host, host) + + defp port?(port) do + # No enormous integer conversion is needed to classify a port with leading zeros. + significant = String.trim_leading(port, "0") + byte_size(significant) in 1..5 and String.to_integer(significant) <= 65535 + end +end diff --git a/apps/control_plane/lib/workharbor/foundation/request.ex b/apps/control_plane/lib/workharbor/foundation/request.ex new file mode 100644 index 00000000..0e93b8a5 --- /dev/null +++ b/apps/control_plane/lib/workharbor/foundation/request.ex @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Foundation.Request do + @moduledoc "Accepted native lifecycle schema. Raw HTTP decoding is a later boundary." + alias WorkHarbor.Foundation.{Canonical, RepositoryIdentity} + @empty_actions ["start", "stop", "remove_runtime"] + + def normalize(owner, key, request) do + valid!(uuid?(owner)) + valid!(key?(key)) + keys!(request, ["schema_version", "workspace_id", "action"], ["parameters"]) + valid!(request["schema_version"] === 1 and uuid?(request["workspace_id"])) + action = request["action"] + parameters = parameters!(action, Map.get(request, "parameters", :missing)) + + normalized = %{ + "schema_version" => 1, + "owner_id" => owner, + "workspace_id" => request["workspace_id"], + "action" => action, + "parameters" => parameters + } + + {:ok, bytes} = Canonical.bytes(normalized) + + {:ok, + %{ + request: normalized, + bytes: bytes, + hash: Base.encode16(:crypto.hash(:sha256, bytes), case: :lower) + }} + catch + :invalid_request -> {:error, :invalid_request} + end + + @doc "Normalizes an immutable lexical config descriptor without fetching or trust resolution." + def normalize_config(value) do + {:ok, config!(value)} + catch + :invalid_request -> {:error, :invalid_request} + end + + def key?(value) when is_binary(value), do: Regex.match?(~r/\A[A-Za-z0-9._:-]{1,128}\z/, value) + def key?(_), do: false + + def uuid?(value) when is_binary(value), + do: Regex.match?(~r/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/, value) + + def uuid?(_), do: false + + defp parameters!(action, :missing) when action in @empty_actions, do: %{} + + defp parameters!(action, value) when action in @empty_actions do + keys!(value, []) + value + end + + defp parameters!("create", value) do + keys!(value, ["host_id", "config"]) + valid!(uuid?(value["host_id"])) + %{"host_id" => value["host_id"], "config" => config!(value["config"])} + end + + defp parameters!("rebuild", value) do + keys!(value, ["config"]) + %{"config" => config!(value["config"])} + end + + defp parameters!(_, _), do: throw(:invalid_request) + + defp config!(value) do + keys!(value, ["repository_identity", "commit"], ["submodules"]) + valid!(RepositoryIdentity.valid?(value["repository_identity"]) and commit?(value["commit"])) + entries = Map.get(value, "submodules", []) + valid!(proper_list?(entries)) + + entries = + Enum.map(entries, fn entry -> + keys!(entry, ["path", "commit"]) + valid!(path?(entry["path"]) and commit?(entry["commit"])) + entry + end) + + valid!(length(Enum.uniq_by(entries, & &1["path"])) == length(entries)) + + %{ + "repository_identity" => value["repository_identity"], + "commit" => value["commit"], + "submodules" => Enum.sort_by(entries, & &1["path"]) + } + end + + defp commit?(value) when is_binary(value), + do: Regex.match?(~r/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/, value) + + defp commit?(_), do: false + + defp path?(value) when is_binary(value) do + String.valid?(value) and value != "" and not String.starts_with?(value, "/") and + not String.contains?(value, [<<0>>, "\\", "\r", "\n", "\t", "*", "?", "[", "]"]) and + Enum.all?(String.split(value, "/"), &(&1 not in ["", ".", "..", ".git"])) + end + + defp path?(_), do: false + + defp keys!(value, required, optional \\ []) do + valid!(is_map(value) and not is_struct(value)) + keys = Map.keys(value) + + valid!( + Enum.all?(required, &Map.has_key?(value, &1)) and + Enum.all?(keys, &(&1 in (required ++ optional))) + ) + end + + defp proper_list?([]), do: true + defp proper_list?([_ | rest]), do: proper_list?(rest) + defp proper_list?(_), do: false + defp valid!(true), do: :ok + defp valid!(_), do: throw(:invalid_request) +end diff --git a/apps/control_plane/lib/workharbor/host_connections.ex b/apps/control_plane/lib/workharbor/host_connections.ex new file mode 100644 index 00000000..71f33044 --- /dev/null +++ b/apps/control_plane/lib/workharbor/host_connections.ex @@ -0,0 +1,449 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostConnections do + @moduledoc "Serialized socket ownership and conservative liveness; no lifecycle dispatcher." + use GenServer + alias WorkHarbor.{Repo, Hosts, Accounts} + alias WorkHarbor.HostConnections.Protocol + alias WorkHarbor.Hosts.Peer + def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) + def open(peer, socket), do: call(__MODULE__, {:open, peer, socket}) + def receive_hello(ref, message), do: peer_call(ref, {:hello, message}) + def heartbeat(ref, message), do: peer_call(ref, {:heartbeat, message}) + def mark_stale(ref, lease), do: peer_call(ref, {:stale, lease}) + + def record_fresh_observation(ref, runtime, generation, intent), + do: peer_call(ref, {:observe, runtime, generation, intent}) + + def disconnect(ref), do: peer_call(ref, :disconnect) + + def project_runtime(principal, runtime) do + with :ok <- Accounts.authorize(principal, :host_status) do + case call(__MODULE__, {:project, principal.owner_id, runtime}) do + {:ok, _} = result -> result + {:error, :not_found} = result -> result + _ -> unknown_projection(principal.owner_id, runtime) + end + end + end + + defp runtime_row(owner, runtime), + do: + Hosts.one( + "SELECT r.*,w.owner_id,w.intent_version FROM runtime_instances r JOIN workspaces w ON r.workspace_id=w.id WHERE r.id=? AND w.owner_id=?", + [runtime, owner] + ) + + defp unknown_projection(owner, runtime) do + case runtime_row(owner, runtime) do + nil -> {:error, :not_found} + row -> {:ok, %{observed_state: "unknown", host_id: row["host_id"]}} + end + end + + def host_state(host), do: call(__MODULE__, {:host_state, host}) + + defp peer_call({server, ref}, message) when is_pid(server) and is_reference(ref), + do: call(server, {:peer, ref, message}) + + defp peer_call(_, _), do: {:error, :unauthorized_host} + + defp call(server, message) do + GenServer.call(server, message, 5000) + catch + :exit, _ -> {:error, :unauthorized_host} + end + + @impl true + def init(opts) do + Repo.put_dynamic_repo(Keyword.get(opts, :repo, Repo)) + + {:ok, _} = + Hosts.transaction(fn -> + rows = + Hosts.sql( + "SELECT host_id,owner_id FROM host_connection_slots WHERE connection_id IS NOT NULL" + ).rows + + Hosts.sql("UPDATE host_connection_slots SET state='unknown',connection_id=NULL") + + Enum.each(rows, fn [host, owner] -> + Hosts.audit(owner, host, "host_connection_reset", "host_restart", "unknown") + end) + end) + + Process.send_after(self(), :tick, 1000) + {:ok, %{peers: %{}, hosts: %{}}} + end + + @impl true + def handle_call({:open, %Peer{} = peer, socket}, _, state) when is_pid(socket) do + if Hosts.current_peer?(peer) do + state = + case state.hosts[peer.host_id] do + nil -> state + old -> close(state, old, :replaced) + end + + id = Ecto.UUID.generate() + ref = make_ref() + mono = monotonic() + + case Hosts.transaction(fn -> + if not Hosts.current_peer?(peer), do: Repo.rollback(:unauthorized_host) + + Hosts.sql( + "INSERT INTO host_connection_slots(host_id,owner_id,connection_id,state) VALUES(?,?,?,'unknown') ON CONFLICT(host_id) DO UPDATE SET connection_id=excluded.connection_id,state='unknown'", + [peer.host_id, peer.owner_id, id] + ) + + Hosts.audit(peer.owner_id, peer.host_id, "host_connected", "host_connect", "unknown") + end) do + {:ok, _} -> + Phoenix.PubSub.subscribe(WorkHarbor.PubSub, "host: " <> peer.host_id) + monitor = Process.monitor(socket) + + expiry = + mono + max(0, min(peer.expires_at, peer.issuer_expires_at) - Accounts.now()) * 1000 + + expiry_timer = Process.send_after(self(), {:expire, ref, id}, max(0, expiry - mono)) + + entry = %{ + peer: peer, + socket: socket, + monitor: monitor, + id: id, + negotiated: false, + opened: mono, + seen: nil, + lease: nil, + expiry: expiry, + expiry_timer: expiry_timer + } + + {:reply, {:ok, {self(), ref}}, + %{ + state + | peers: Map.put(state.peers, ref, entry), + hosts: Map.put(state.hosts, peer.host_id, ref) + }} + + _ -> + {:reply, {:error, :unauthorized_host}, state} + end + else + {:reply, {:error, :unauthorized_host}, state} + end + end + + def handle_call({:open, _, _}, _, state), do: {:reply, {:error, :unauthorized_host}, state} + + def handle_call({:peer, ref, message}, _, state) do + case state.peers[ref] do + nil -> + {:reply, {:error, :unauthorized_host}, state} + + entry -> + cond do + not valid_entry?(entry) -> + {:reply, {:error, :unauthorized_host}, close(state, ref, :identity_invalid)} + + stale?(entry) -> + {state, result} = close_result(state, ref, :stale) + reply = if result == :ok, do: {:error, :unauthorized_host}, else: result + {:reply, reply, state} + + true -> + peer_message(state, ref, entry, message) + end + end + end + + def handle_call({:project, owner, runtime}, _, state) do + result = + Hosts.transaction(fn -> + case runtime_row(owner, runtime) do + nil -> + {:error, :not_found} + + row -> + entry = state.peers[state.hosts[row["host_id"]]] + + annotation = + Hosts.one("SELECT * FROM runtime_freshness WHERE runtime_id=?", [runtime]) + + known = + entry && annotation && fresh?(entry) && row["is_current"] == 1 && + is_nil(row["deleted_at"]) && + annotation["connection_id"] == entry.id && + annotation["generation"] === row["generation"] && + annotation["intent_version"] === row["intent_version"] + + {:ok, + %{ + observed_state: if(known, do: row["observed_state"], else: "unknown"), + host_id: row["host_id"] + }} + end + end) + + reply = + case result do + {:ok, value} -> value + _ -> {:error, :persistence_failed} + end + + {:reply, reply, state} + end + + def handle_call({:host_state, host}, _, state) do + entry = state.peers[state.hosts[host]] + {:reply, {:ok, if(entry && fresh?(entry), do: "connected", else: "unknown")}, state} + end + + defp peer_message(state, ref, entry, {:hello, message}) do + with false <- entry.negotiated, + {:ok, %{"kind" => "hello"}} <- + Protocol.validate(message, entry.peer.owner_id, entry.peer.host_id) do + entry = %{entry | negotiated: true} + {:reply, {:ok, ack(entry, "hello_ack")}, put_entry(state, ref, entry)} + else + _ -> {:reply, {:error, :invalid_connection_message}, close(state, ref, :invalid_message)} + end + end + + defp peer_message(state, ref, entry, {:heartbeat, message}) do + with true <- entry.negotiated, + {:ok, %{"kind" => "heartbeat"}} <- + Protocol.validate(message, entry.peer.owner_id, entry.peer.host_id) do + case Hosts.transaction(fn -> + current_slot!(entry) + if not Hosts.current_peer?(entry.peer), do: Repo.rollback(:unauthorized_host) + now = DateTime.from_unix!(Accounts.now()) |> DateTime.to_iso8601() + lease = DateTime.from_unix!(Accounts.now() + 30) |> DateTime.to_iso8601() + + Hosts.sql("UPDATE hosts SET last_seen=?,lease_expires_at=? WHERE id=?", [ + now, + lease, + entry.peer.host_id + ]) + + Hosts.sql( + "UPDATE host_connection_slots SET state='connected' WHERE host_id=? AND connection_id=?", + [entry.peer.host_id, entry.id] + ) + + Hosts.audit( + entry.peer.owner_id, + entry.peer.host_id, + "host_heartbeat", + "host_heartbeat", + "authenticated" + ) + end) do + {:ok, _} -> + entry = %{entry | seen: monotonic(), lease: make_ref()} + Process.send_after(self(), {:stale, ref, entry.lease}, 30000) + {:reply, {:ok, ack(entry, "heartbeat_ack")}, put_entry(state, ref, entry)} + + _ -> + {:reply, {:error, :persistence_failed}, close(state, ref, :persistence_failed)} + end + else + _ -> {:reply, {:error, :invalid_connection_message}, close(state, ref, :invalid_message)} + end + end + + defp peer_message(state, ref, entry, {:stale, lease}) do + if entry.lease === lease and stale?(entry) do + {state, result} = close_result(state, ref, :stale) + {:reply, result, state} + else + {:reply, :ok, state} + end + end + + defp peer_message(state, _ref, entry, {:observe, runtime, generation, intent}) do + if fresh?(entry) and is_integer(generation) and generation > 0 and is_integer(intent) and + intent > 0 do + result = + Hosts.transaction(fn -> + current_slot!(entry) + if not fresh?(entry), do: Repo.rollback(:stale_observation) + + row = + Hosts.one( + "SELECT r.workspace_id FROM runtime_instances r JOIN workspaces w ON w.id=r.workspace_id WHERE r.id=? AND r.generation=? AND r.host_id=? AND w.owner_id=? AND w.intent_version=? AND r.is_current=1 AND r.deleted_at IS NULL", + [runtime, generation, entry.peer.host_id, entry.peer.owner_id, intent] + ) + + if is_nil(row), do: Repo.rollback(:stale_observation) + + Hosts.sql( + "INSERT INTO runtime_freshness(runtime_id,generation,intent_version,connection_id) VALUES(?,?,?,?) ON CONFLICT(runtime_id) DO UPDATE SET generation=excluded.generation,intent_version=excluded.intent_version,connection_id=excluded.connection_id", + [runtime, generation, intent, entry.id] + ) + + Hosts.audit( + entry.peer.owner_id, + entry.peer.host_id, + "runtime_freshness_recorded", + "runtime_observed", + "recorded", + workspace_id: row["workspace_id"], + runtime_id: runtime, + generation: generation, + intent_version: intent + ) + + :ok + end) + + {:reply, if(result == {:ok, :ok}, do: :ok, else: result), state} + else + {:reply, {:error, :stale_observation}, state} + end + end + + defp peer_message(state, ref, _entry, :disconnect) do + {state, result} = close_result(state, ref, :disconnected) + {:reply, result, state} + end + + defp peer_message(state, ref, _entry, _), + do: {:reply, {:error, :invalid_connection_message}, close(state, ref, :invalid_message)} + + @impl true + def handle_info(:tick, state) do + state = sweep(state) + Process.send_after(self(), :tick, 1000) + {:noreply, state} + end + + def handle_info(:identity_changed, state), do: {:noreply, sweep(state)} + + def handle_info({:stale, ref, lease}, state) do + entry = state.peers[ref] + + {:noreply, + if(entry && entry.lease === lease && stale?(entry), + do: close(state, ref, :stale), + else: state + )} + end + + def handle_info({:expire, ref, id}, state) do + entry = state.peers[ref] + + {:noreply, + if(entry && entry.id == id && monotonic() >= entry.expiry, + do: close(state, ref, :identity_invalid), + else: state + )} + end + + def handle_info({:DOWN, monitor, :process, _, _}, state) do + ref = + Enum.find_value(state.peers, fn {ref, entry} -> if entry.monitor == monitor, do: ref end) + + {:noreply, if(ref, do: close(state, ref, :disconnected), else: state)} + end + + defp sweep(state), + do: + Enum.reduce(Map.keys(state.peers), state, fn ref, acc -> + entry = acc.peers[ref] + + cond do + not valid_entry?(entry) -> close(acc, ref, :identity_invalid) + stale?(entry) -> close(acc, ref, :stale) + true -> acc + end + end) + + defp close(state, ref, reason), do: elem(close_result(state, ref, reason), 0) + + defp close_result(state, ref, reason) do + case state.peers[ref] do + nil -> + {state, :ok} + + entry -> + Process.demonitor(entry.monitor, [:flush]) + Process.cancel_timer(entry.expiry_timer) + + result = + Hosts.transaction(fn -> + changed = + Hosts.sql( + "UPDATE host_connection_slots SET state='unknown' WHERE host_id=? AND connection_id=?", + [entry.peer.host_id, entry.id] + ) + + if changed.num_rows == 1, + do: + Hosts.audit( + entry.peer.owner_id, + entry.peer.host_id, + "host_connection_unknown", + "host_disconnect", + "unknown" + ) + end) + + outcome = if match?({:ok, _}, result), do: :ok, else: {:error, :persistence_failed} + + send( + entry.socket, + {:host_connection_close, if(outcome == :ok, do: reason, else: :persistence_failed)} + ) + + {%{ + state + | peers: Map.delete(state.peers, ref), + hosts: Map.delete(state.hosts, entry.peer.host_id) + }, outcome} + end + end + + defp current_slot!(entry) do + row = + Hosts.one("SELECT host_id FROM host_connection_slots WHERE host_id=? AND connection_id=?", [ + entry.peer.host_id, + entry.id + ]) + + if is_nil(row), do: Repo.rollback(:unauthorized_host) + end + + defp valid_entry?(entry), + do: + monotonic() < entry.expiry and Hosts.current_peer?(entry.peer) and + not is_nil( + Hosts.one( + "SELECT host_id FROM host_connection_slots WHERE host_id=? AND connection_id=?", + [entry.peer.host_id, entry.id] + ) + ) + + defp fresh?(entry), + do: entry.negotiated and not is_nil(entry.seen) and not stale?(entry) and valid_entry?(entry) + + defp stale?(entry), do: monotonic() - (entry.seen || entry.opened) >= 30000 + + defp monotonic, + do: + Application.get_env(:workharbor, :host_monotonic_clock, fn -> + System.monotonic_time(:millisecond) + end).() + + defp ack(entry, kind), + do: %{ + "schema_version" => 1, + "kind" => kind, + "owner_id" => entry.peer.owner_id, + "host_id" => entry.peer.host_id + } + + defp put_entry(state, ref, entry), do: %{state | peers: Map.put(state.peers, ref, entry)} +end diff --git a/apps/control_plane/lib/workharbor/host_connections/protocol.ex b/apps/control_plane/lib/workharbor/host_connections/protocol.ex new file mode 100644 index 00000000..a9d2cdcf --- /dev/null +++ b/apps/control_plane/lib/workharbor/host_connections/protocol.ex @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostConnections.Protocol do + @moduledoc false + alias WorkHarbor.{StrictJSON} + alias WorkHarbor.Foundation.{Request, Canonical} + + def decode(raw, owner, host) when is_binary(raw) and byte_size(raw) <= 4096 do + with {:ok, value} <- StrictJSON.decode_object(raw), do: validate(value, owner, host) + end + + def decode(_, _, _), do: {:error, :invalid_connection_message} + + def validate(value, owner, host) do + keys = + if is_map(value) and value["kind"] == "hello", + do: ~w(schema_version kind owner_id host_id capabilities), + else: ~w(schema_version kind owner_id host_id) + + valid = + is_map(value) and not is_struct(value) and Enum.sort(Map.keys(value)) == Enum.sort(keys) and + value["schema_version"] === 1 and + value["kind"] in ~w(hello hello_ack heartbeat heartbeat_ack) and Request.uuid?(owner) and + Request.uuid?(host) and + value["owner_id"] === owner and value["host_id"] === host and + (value["kind"] != "hello" or value["capabilities"] in [[], ["docker"]]) + + if valid do + case Canonical.bytes(value) do + {:ok, bytes} when byte_size(bytes) <= 4096 -> {:ok, value} + _ -> {:error, :invalid_connection_message} + end + else + {:error, :invalid_connection_message} + end + end +end diff --git a/apps/control_plane/lib/workharbor/host_protocol.ex b/apps/control_plane/lib/workharbor/host_protocol.ex new file mode 100644 index 00000000..cdd61908 --- /dev/null +++ b/apps/control_plane/lib/workharbor/host_protocol.ex @@ -0,0 +1,410 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostProtocol.Peer do + @moduledoc "Caller-supplied verified peer scope, never decoded from wire data." + defstruct [:kind, :owner_id, :host_id] +end + +defmodule WorkHarbor.HostProtocol.Snapshot do + @moduledoc "Authoritative operation/command expectation; not a durable ledger." + defstruct [ + :command, + :request_bytes, + :request_hash, + :native_bindings, + :current_intent, + :status, + :command_bytes, + :terminal_result, + runtimes: %{} + ] +end + +defmodule WorkHarbor.HostProtocol.Context do + @moduledoc "Trusted peer and separately supplied operation expectation." + defstruct [:peer, :snapshot] +end + +defmodule WorkHarbor.HostProtocol do + @moduledoc "Pure closed host protocol validation. No returned disposition permits effects." + alias WorkHarbor.{StrictJSON} + alias WorkHarbor.Foundation.{Request, Canonical} + alias __MODULE__.{Context, Peer, Snapshot} + @limit 1_048_576 + @max 9_223_372_036_854_775_807 + @integer_limit Integer.pow(10, 4300) + @common ~w(schema_version kind command_id owner_id host_id workspace_id operation_id idempotency_key request_sha256 intent_version bindings) + @command @common ++ ~w(request execution_config approval_ref) + @states ~w(pending provisioning running stopping stopped failed unknown) + + def decode(raw, context) do + case StrictJSON.decode_object(raw) do + {:ok, value} -> validate(value, context) + {:error, :input_limit_exceeded} -> {:error, :input_limit} + _ -> {:error, :invalid_json} + end + end + + def validate(value, context) do + budget!(value, 0, @limit) + message = shape!(value) + budget!(message, 0, @limit) + {peer, snapshot} = scope!(message, context) + budget!(snapshot.command, 0, @limit) + expected = command!(snapshot.command) + + valid!( + expected["owner_id"] == peer.owner_id and expected["host_id"] == peer.host_id, + :unauthorized_scope + ) + + valid!(message["workspace_id"] == expected["workspace_id"], :unauthorized_scope) + + for field <- @common -- ["kind"] do + valid!(message[field] === expected[field], :identity_conflict) + end + + {:ok, normalized} = + Request.normalize(peer.owner_id, expected["idempotency_key"], expected["request"]) + + valid!( + normalized.bytes === snapshot.request_bytes and normalized.hash === snapshot.request_hash and + expected["request_sha256"] === snapshot.request_hash, + :identity_conflict + ) + + native = native_bindings!(message["bindings"]) + valid!(native === snapshot.native_bindings, :stale_runtime) + {:ok, canonical} = Canonical.bytes(message) + + {disposition, result} = + if message["kind"] == "command" do + valid!(message === expected, :identity_conflict) + + if not is_nil(snapshot.command_bytes), + do: valid!(snapshot.command_bytes === canonical, :identity_conflict) + + {command_disposition!(snapshot, message["intent_version"]), nil} + else + result = %{ + "outcome" => message["outcome"], + "observation" => Map.get(message, "observation") + } + + if snapshot.status in ["succeeded", "failed"] do + valid!(snapshot.terminal_result === result, :identity_conflict) + {"completed_lookup", result} + else + current!(snapshot, message["intent_version"]) + + valid!( + snapshot.status in ["queued", "executing"] and + (message["outcome"]["status"] != "succeeded" or snapshot.status == "executing"), + :invalid_status + ) + + observe!(result, native, expected, snapshot) + {"new_eligible", result} + end + end + + {:ok, + %{ + message: message, + canonical: canonical, + native_bindings: native, + native_result: result, + disposition: disposition + }} + catch + code + when code in [ + :invalid_message, + :unsupported_version, + :unauthorized_scope, + :identity_conflict, + :stale_intent, + :stale_runtime, + :invalid_status, + :transport_refused + ] -> + {:error, code} + end + + defp shape!(v) do + valid!(is_map(v) and not is_struct(v)) + + if is_integer(v["schema_version"]) and v["schema_version"] !== 1, + do: throw(:unsupported_version) + + valid!(v["schema_version"] === 1) + + case v["kind"] do + "command" -> + command!(v) + + "result" -> + keys!(v, @common ++ ["outcome"], ["observation"]) + common!(v) + + valid!( + v["outcome"] === %{"status" => "succeeded", "code" => "ok"} or + v["outcome"] === %{"status" => "failed", "code" => "operation_failed"} + ) + + if Map.has_key?(v, "observation"), + do: Map.put(v, "observation", observation!(v["observation"])), + else: v + + _ -> + throw(:invalid_message) + end + end + + defp command!(v) do + keys!(v, @command) + valid!(v["schema_version"] === 1 and v["kind"] == "command") + common!(v) + + case Request.normalize(v["owner_id"], v["idempotency_key"], v["request"]) do + {:ok, normalized} -> valid!(Map.delete(normalized.request, "owner_id") === v["request"]) + _ -> throw(:invalid_message) + end + + req = v["request"] + valid!(req["workspace_id"] == v["workspace_id"]) + action = req["action"] + b = v["bindings"] + + case action do + "create" -> + valid!( + req["parameters"]["host_id"] == v["host_id"] and is_nil(b["target"]) and + not is_nil(b["candidate"]) + ) + + "rebuild" -> + valid!(not is_nil(b["candidate"])) + + "start" -> + valid!(not is_nil(b["target"]) and is_nil(b["candidate"])) + + _ -> + valid!(is_nil(b["candidate"])) + end + + if action == "start", + do: config!(v["execution_config"], true), + else: valid!(is_nil(v["execution_config"])) + + if action in ["create", "rebuild"], + do: valid!(Request.uuid?(v["approval_ref"])), + else: valid!(is_nil(v["approval_ref"])) + + v + end + + defp common!(v) do + for field <- ~w(command_id owner_id host_id workspace_id operation_id), + do: valid!(Request.uuid?(v[field])) + + valid!(Request.key?(v["idempotency_key"])) + + valid!( + is_binary(v["request_sha256"]) and Regex.match?(~r/\A[0-9a-f]{64}\z/, v["request_sha256"]) + ) + + counter!(v["intent_version"]) + native_bindings!(v["bindings"]) + end + + defp native_bindings!(b) do + keys!(b, ~w(target candidate)) + + Map.new(b, fn {role, value} -> + if is_nil(value) do + {role, nil} + else + keys!(value, ~w(runtime_id generation)) + valid!(Request.uuid?(value["runtime_id"])) + counter!(value["generation"]) + {role, %{"id" => value["runtime_id"], "generation" => value["generation"]}} + end + end) + end + + defp observation!(o) do + keys!( + o, + ~w(runtime_id generation observed_state), + ~w(provider_instance_id effective_config deleted) + ) + + valid!(Request.uuid?(o["runtime_id"]) and o["observed_state"] in @states) + counter!(o["generation"]) + provider = Map.get(o, "provider_instance_id") + valid!(is_nil(provider) or (is_binary(provider) and String.valid?(provider))) + deleted = Map.get(o, "deleted", false) + valid!(deleted === true or deleted === false) + + effective = + case Map.get(o, "effective_config") do + nil -> nil + config -> config!(config, true) + end + + o + |> Map.put("provider_instance_id", provider) + |> Map.put("effective_config", effective) + |> Map.put("deleted", deleted) + end + + defp observe!(%{"observation" => nil}, _, _, _), do: :ok + + defp observe!(%{"observation" => o, "outcome" => outcome}, bindings, command, snapshot) do + identity = %{"id" => o["runtime_id"], "generation" => o["generation"]} + role = Enum.find_value(bindings, fn {role, value} -> if value === identity, do: role end) + valid!(not is_nil(role), :stale_runtime) + runtime = Map.get(snapshot.runtimes, o["runtime_id"]) + valid!(is_map(runtime), :stale_runtime) + provider = o["provider_instance_id"] + effective = o["effective_config"] + + if not is_nil(provider) and not is_nil(runtime[:provider_id]), + do: valid!(provider === runtime[:provider_id], :stale_runtime) + + if not is_nil(effective) do + if role == "target", do: valid!(not is_nil(runtime[:effective_config]), :stale_runtime) + + if not is_nil(runtime[:effective_config]), + do: valid!(effective === runtime[:effective_config], :stale_runtime) + + expected = + if role == "candidate", + do: command["request"]["parameters"]["config"], + else: command["execution_config"] + + if role == "candidate" or command["request"]["action"] == "start", + do: valid!(effective === expected, :stale_runtime) + end + + if o["deleted"] do + valid!( + command["request"]["action"] == "remove_runtime" and role == "target" and + outcome["status"] == "succeeded" and o["observed_state"] == "stopped" + ) + end + end + + defp scope!(m, %Context{peer: %Peer{} = peer, snapshot: %Snapshot{} = snapshot}) do + kind = if m["kind"] == "command", do: :control_plane, else: :host + + valid!( + peer.kind == kind and Request.uuid?(peer.owner_id) and Request.uuid?(peer.host_id) and + peer.owner_id == m["owner_id"] and peer.host_id == m["host_id"], + :unauthorized_scope + ) + + {peer, snapshot} + end + + defp scope!(_, _), do: throw(:unauthorized_scope) + + defp command_disposition!(%Snapshot{status: status}, _) when status in ["succeeded", "failed"], + do: "completed_lookup" + + defp command_disposition!(s, intent) do + current!(s, intent) + + case s.status do + "executing" -> "reconciliation_required" + "queued" -> if is_nil(s.command_bytes), do: "new_eligible", else: "pending_lookup" + _ -> throw(:invalid_status) + end + end + + defp current!(s, intent), do: valid!(s.current_intent === intent, :stale_intent) + + defp config!(v, strict) do + case Request.normalize_config(v) do + {:ok, normalized} -> + if strict, do: valid!(v === normalized) + normalized + + _ -> + throw(:invalid_message) + end + end + + defp keys!(v, required, optional \\ []) do + valid!(is_map(v) and not is_struct(v)) + + valid!( + Enum.all?(required, &Map.has_key?(v, &1)) and + Enum.all?(Map.keys(v), &(&1 in (required ++ optional))) + ) + end + + defp counter!(v), do: valid!(is_integer(v) and v >= 1 and v <= @max) + defp valid!(v, code \\ :invalid_message) + defp valid!(true, _), do: :ok + defp valid!(_, code), do: throw(code) + + # Count exact canonical size while retaining the generic native profile. Never + # call the unbounded canonical allocator until this pass has succeeded. + defp budget!(_, _, left) when left < 0, do: throw(:transport_refused) + defp budget!(nil, _, left), do: spend!(left, 4) + defp budget!(true, _, left), do: spend!(left, 4) + defp budget!(false, _, left), do: spend!(left, 5) + + defp budget!(v, _, left) when is_integer(v) do + valid!(v > -@integer_limit and v < @integer_limit) + spend!(left, byte_size(Integer.to_string(v))) + end + + defp budget!(v, _, left) when is_binary(v) do + if byte_size(v) > left, do: throw(:transport_refused) + valid!(String.valid?(v)) + + string_budget!(v, spend!(left, 2)) + end + + defp budget!(v, depth, left) when is_list(v) and depth < 128 do + list_budget!(v, depth + 1, spend!(left, 2), false) + end + + defp budget!(v, depth, left) when is_map(v) and not is_struct(v) and depth < 128 do + Enum.reduce(v, {spend!(left, 2), false}, fn {k, value}, {remaining, comma} -> + valid!(is_binary(k)) + remaining = budget!(k, depth + 1, spend!(remaining, if(comma, do: 2, else: 1))) + {budget!(value, depth + 1, remaining), true} + end) + |> elem(0) + end + + defp budget!(_, _, _), do: throw(:invalid_message) + defp list_budget!([], _, left, _), do: left + + defp list_budget!([v | rest], depth, left, comma), + do: + list_budget!(rest, depth, budget!(v, depth, spend!(left, if(comma, do: 1, else: 0))), true) + + defp list_budget!(_, _, _, _), do: throw(:invalid_message) + + defp string_budget!(<<>>, left), do: left + + defp string_budget!(<>, left) do + n = + cond do + c in [34, 92, 8, 12, 10, 13, 9] -> 2 + c < 32 -> 6 + true -> byte_size(<>) + end + + string_budget!(rest, spend!(left, n)) + end + + defp spend!(left, n) do + if left < n, do: throw(:transport_refused), else: left - n + end +end diff --git a/apps/control_plane/lib/workharbor/hosts.ex b/apps/control_plane/lib/workharbor/hosts.ex new file mode 100644 index 00000000..ffe5ada9 --- /dev/null +++ b/apps/control_plane/lib/workharbor/hosts.ex @@ -0,0 +1,425 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Hosts.Peer do + @moduledoc "Internal certificate-registry scope. Transport must prove private-key possession." + @enforce_keys [:owner_id, :host_id, :identity_id, :certificate, :expires_at, :issuer_expires_at] + defstruct @enforce_keys +end + +defmodule WorkHarbor.Hosts do + @moduledoc "Atomic one-time enrollment and retained scoped client identities." + alias WorkHarbor.{Repo, Accounts} + alias WorkHarbor.Foundation.{Host, AuditEvent, Request} + alias WorkHarbor.Hosts.{Issuer, Peer} + + def issue_enrollment(principal, host_id) do + with :ok <- Accounts.authorize(principal, :host_enrollment_issue) do + transaction(fn -> + authorize!(principal, :host_enrollment_issue) + + host = + if is_nil(host_id) do + host = Repo.insert!(%Host{owner_id: principal.owner_id}) + + audit(host.owner_id, host.id, "host_recorded", "host_metadata", "recorded", + channel: channel(principal) + ) + + host + else + scoped_host!(principal.owner_id, host_id) + end + + sql( + "INSERT INTO host_security(host_id,owner_id) VALUES(?,?) ON CONFLICT(host_id) DO NOTHING", + [host.id, host.owner_id] + ) + + not_revoked!(host.id, :host_revoked) + code = "whe1_" <> Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) + now = Accounts.now() + + sql( + "INSERT INTO host_enrollments(id,host_id,owner_id,code_hash,issued_at,expires_at) VALUES(?,?,?,?,?,?)", + [Ecto.UUID.generate(), host.id, host.owner_id, digest(code), now, now + 300] + ) + + audit(host.owner_id, host.id, "host_enrollment_issued", "host_enrollment_issue", "issued", + channel: channel(principal) + ) + + %{host_id: host.id, owner_id: host.owner_id, code: code, expires_at: now + 300} + end) + end + end + + def redeem(code, csr) do + with true <- code?(code), {:ok, issuer} <- issuer() do + transaction(fn -> + enrollment = one("SELECT * FROM host_enrollments WHERE code_hash=?", [digest(code)]) + now = Accounts.now() + + if is_nil(enrollment) or enrollment["issued_at"] > now or enrollment["expires_at"] <= now or + not is_nil(enrollment["consumed_at"]) or not is_nil(enrollment["revoked_at"]), + do: Repo.rollback(:invalid_enrollment) + + not_revoked!(enrollment["host_id"], :invalid_enrollment) + + {:ok, cert} = + or_rollback(Issuer.issue(issuer, csr, enrollment["host_id"], :enrollment, now)) + + sql("UPDATE host_enrollments SET consumed_at=? WHERE id=? AND consumed_at IS NULL", [ + now, + enrollment["id"] + ]) + + sql( + "UPDATE host_identities SET status='retired' WHERE host_id=? AND status IN ('active','pending')", + [enrollment["host_id"]] + ) + + identity = + insert_identity( + enrollment["owner_id"], + enrollment["host_id"], + cert, + csr, + nil, + "active", + now + ) + + sql("UPDATE hosts SET identity_fingerprint=? WHERE id=?", [ + identity["fingerprint"], + identity["host_id"] + ]) + + audit( + identity["owner_id"], + identity["host_id"], + "host_enrolled", + "host_enrollment", + "issued" + ) + + response(identity, issuer) + end) + else + false -> {:error, :invalid_enrollment} + error -> error + end + end + + def resolve_identity(cert, mode) + when is_binary(cert) and byte_size(cert) <= 4096 and mode in [:active, :activation] do + with {:ok, issuer} <- issuer(), + identity when is_map(identity) <- + one("SELECT * FROM host_identities WHERE fingerprint=?", [fingerprint(cert)]), + true <- + identity["certificate"] == cert and allowed_status?(identity, mode) and + not_revoked?(identity["host_id"]), + true <- Issuer.valid_leaf?(cert, issuer, identity["host_id"], Accounts.now()) do + {:ok, + %Peer{ + owner_id: identity["owner_id"], + host_id: identity["host_id"], + identity_id: identity["id"], + certificate: cert, + expires_at: identity["expires_at"], + issuer_expires_at: issuer.expires_at + }} + else + _ -> {:error, :unauthorized_host} + end + rescue + _ -> {:error, :unauthorized_host} + catch + _ -> {:error, :unauthorized_host} + end + + def resolve_identity(_, _), do: {:error, :unauthorized_host} + + def current_peer?(%Peer{} = peer, mode \\ :active) do + case resolve_identity(peer.certificate, mode) do + {:ok, current} -> current === peer + _ -> false + end + end + + def renew(%Peer{} = peer, csr) do + with {:ok, issuer} <- issuer() do + transaction(fn -> + if not current_peer?(peer), do: Repo.rollback(:unauthorized_host) + {:ok, _} = or_rollback(Issuer.csr_public(csr, peer.host_id)) + + pending = + one("SELECT * FROM host_identities WHERE host_id=? AND status='pending'", [peer.host_id]) + + if pending do + if pending["predecessor_id"] != peer.identity_id or + pending["csr_hash"] != :crypto.hash(:sha256, csr), + do: Repo.rollback(:rotation_conflict) + + if pending["expires_at"] <= Accounts.now(), do: Repo.rollback(:unauthorized_host) + response(pending, issuer) + else + {:ok, cert} = or_rollback(Issuer.issue(issuer, csr, peer.host_id, :renewal)) + + identity = + insert_identity( + peer.owner_id, + peer.host_id, + cert, + csr, + peer.identity_id, + "pending", + Accounts.now() + ) + + audit(peer.owner_id, peer.host_id, "host_rotation_staged", "host_renew", "staged") + response(identity, issuer) + end + end) + end + end + + def renew(_, _), do: {:error, :unauthorized_host} + + def activate(%Peer{} = peer) do + result = + transaction(fn -> + if not current_peer?(peer, :activation), do: Repo.rollback(:unauthorized_host) + row = one("SELECT * FROM host_identities WHERE id=?", [peer.identity_id]) + + if row["status"] == "active" do + if is_nil(row["predecessor_id"]), do: Repo.rollback(:unauthorized_host) + else + old = + one("SELECT * FROM host_identities WHERE id=? AND host_id=? AND status='active'", [ + row["predecessor_id"], + peer.host_id + ]) + + if is_nil(old) or old["expires_at"] <= Accounts.now(), + do: Repo.rollback(:unauthorized_host) + + sql("UPDATE host_identities SET status='retired' WHERE id=?", [old["id"]]) + + sql("UPDATE host_identities SET status='active',activated_at=? WHERE id=?", [ + Accounts.now(), + row["id"] + ]) + + sql("UPDATE hosts SET identity_fingerprint=? WHERE id=?", [ + row["fingerprint"], + peer.host_id + ]) + + audit( + peer.owner_id, + peer.host_id, + "host_rotation_activated", + "host_activate", + "activated" + ) + end + + :ok + end) + + if result == {:ok, :ok}, + do: Phoenix.PubSub.broadcast(WorkHarbor.PubSub, "host: " <> peer.host_id, :identity_changed) + + normalize_ok(result) + end + + def activate(_), do: {:error, :unauthorized_host} + + def revoke(principal, host_id) do + with :ok <- Accounts.authorize(principal, :host_revoke) do + result = + transaction(fn -> + authorize!(principal, :host_revoke) + host = scoped_host!(principal.owner_id, host_id) + + sql( + "INSERT INTO host_security(host_id,owner_id) VALUES(?,?) ON CONFLICT(host_id) DO NOTHING", + [host.id, host.owner_id] + ) + + unless revoked?(host.id) do + now = Accounts.now() + sql("UPDATE host_security SET revoked_at=? WHERE host_id=?", [now, host.id]) + + sql( + "UPDATE host_enrollments SET revoked_at=? WHERE host_id=? AND revoked_at IS NULL", + [now, host.id] + ) + + sql( + "UPDATE host_identities SET status='revoked' WHERE host_id=? AND status != 'revoked'", + [host.id] + ) + + sql("UPDATE host_connection_slots SET state='unknown' WHERE host_id=?", [host.id]) + + audit(host.owner_id, host.id, "host_revoked", "host_revoke", "revoked", + channel: channel(principal) + ) + end + + :ok + end) + + if result == {:ok, :ok}, + do: Phoenix.PubSub.broadcast(WorkHarbor.PubSub, "host: " <> host_id, :identity_changed) + + normalize_ok(result) + end + end + + def status(principal, host_id) do + with :ok <- Accounts.authorize(principal, :host_status) do + transaction(fn -> + host = scoped_host!(principal.owner_id, host_id) + %{host_id: host.id, owner_id: host.owner_id, revoked: revoked?(host.id)} + end) + end + end + + def issuer do + config = Application.get_env(:workharbor, :host_identity, []) + + case config[:issuer_dir] do + path when is_binary(path) -> Issuer.load(path) + _ -> {:error, :issuer_unavailable} + end + end + + def fingerprint(cert), do: :crypto.hash(:sha256, cert) |> Base.encode16(case: :lower) + def sql(statement, parameters \\ []), do: Repo.query!(statement, parameters, log: false) + + def one(statement, parameters \\ []) do + case sql(statement, parameters) do + %{columns: cols, rows: [row]} -> Map.new(Enum.zip(cols, row)) + _ -> nil + end + end + + def transaction(fun) do + if Repo.in_transaction?(), + do: {:error, :nested_transaction}, + else: Repo.transaction(fun, mode: :immediate, log: false) + rescue + _ in [Exqlite.Error, DBConnection.ConnectionError, Ecto.ConstraintError] -> + {:error, :persistence_failed} + end + + def audit(owner, host, event, action, outcome, opts \\ []) do + Repo.insert!( + %AuditEvent{ + owner_id: owner, + host_id: host, + event: event, + action: action, + outcome: outcome, + channel: opts[:channel] || "internal", + workspace_id: opts[:workspace_id], + runtime_id: opts[:runtime_id], + runtime_generation: opts[:generation], + intent_version: opts[:intent_version] + }, + log: false + ) + end + + defp code?("whe1_" <> encoded) when byte_size(encoded) == 43 do + case Base.url_decode64(encoded, padding: false) do + {:ok, bytes} -> + byte_size(bytes) == 32 and Base.url_encode64(bytes, padding: false) == encoded + + _ -> + false + end + end + + defp code?(_), do: false + defp digest(code), do: :crypto.hash(:sha256, "WorkHarbor enrollment v1\0" <> code) + defp allowed_status?(row, :active), do: row["status"] == "active" + + defp allowed_status?(row, :activation), + do: + row["status"] == "pending" or + (row["status"] == "active" and not is_nil(row["predecessor_id"])) + + defp not_revoked?(host), + do: + not is_nil( + one("SELECT host_id FROM host_security WHERE host_id=? AND revoked_at IS NULL", [host]) + ) + + defp revoked?(host), + do: + not is_nil( + one("SELECT host_id FROM host_security WHERE host_id=? AND revoked_at IS NOT NULL", [host]) + ) + + defp not_revoked!(host, error), do: if(not not_revoked?(host), do: Repo.rollback(error)) + + defp scoped_host!(owner, id) do + if not Request.uuid?(id), do: Repo.rollback(:not_found) + Repo.get_by(Host, id: id, owner_id: owner) || Repo.rollback(:not_found) + end + + defp authorize!(p, action), + do: if(Accounts.authorize(p, action) != :ok, do: Repo.rollback(:forbidden)) + + defp channel(%{kind: "cli_owner"}), do: "cli" + defp channel(_), do: "browser" + defp or_rollback({:ok, _} = result), do: result + defp or_rollback({:error, error}), do: Repo.rollback(error) + defp normalize_ok({:ok, :ok}), do: :ok + defp normalize_ok(error), do: error + + defp insert_identity(owner, host, certificate, csr, predecessor, status, now) do + id = Ecto.UUID.generate() + times = Issuer.inspect_certificate(certificate) + + sql( + "INSERT INTO host_identities(id,host_id,owner_id,fingerprint,certificate,csr_hash,predecessor_id,status,issued_at,expires_at,activated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + [ + id, + host, + owner, + fingerprint(certificate), + certificate, + :crypto.hash(:sha256, csr), + predecessor, + status, + times.not_before, + times.not_after, + if(status == "active", do: now, else: nil) + ] + ) + + one("SELECT * FROM host_identities WHERE id=?", [id]) + end + + defp response(row, issuer) do + if byte_size(row["certificate"]) > 4096 or byte_size(issuer.certificate) > 4096, + do: Repo.rollback(:identity_response_refused) + + result = %{ + "schema_version" => 1, + "owner_id" => row["owner_id"], + "host_id" => row["host_id"], + "identity_id" => row["id"], + "certificate" => Base.encode64(row["certificate"]), + "issuer_certificate" => Base.encode64(issuer.certificate), + "expires_at" => row["expires_at"] + } + + if byte_size(Jason.encode!(result)) > 16384, do: Repo.rollback(:identity_response_refused) + result + end +end diff --git a/apps/control_plane/lib/workharbor/hosts/issuer.ex b/apps/control_plane/lib/workharbor/hosts/issuer.ex new file mode 100644 index 00000000..52fbe457 --- /dev/null +++ b/apps/control_plane/lib/workharbor/hosts/issuer.ex @@ -0,0 +1,311 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Hosts.Issuer do + @moduledoc "Native client identity issuer; initialization is explicit and exclusive." + require Record + + for {name, tag} <- [ + tbs: :OTPTBSCertificate, + cert: :OTPCertificate, + validity: :Validity, + signature: :SignatureAlgorithm, + public_algorithm: :PublicKeyAlgorithm, + spki: :OTPSubjectPublicKeyInfo, + extension: :Extension, + basic: :BasicConstraints + ] do + Record.defrecordp( + name, + tag, + Record.extract(tag, from_lib: "public_key/include/public_key.hrl") + ) + end + + @ed {1, 3, 101, 112} + @day 86400 + def initialize(dir) do + path = Path.join(dir, "issuer.json") + + with true <- Path.type(dir) == :absolute, + :ok <- WorkHarbor.Hosts.PrivateFile.traversal(dir, true), + :ok <- ensure_dir(dir), + :ok <- WorkHarbor.Hosts.PrivateFile.directory(dir) do + key = :public_key.generate_key({:namedCurve, @ed}) + public = elem(key, 4) + now = WorkHarbor.Accounts.now() + name = subject("WorkHarbor client issuer") + certificate = sign(key, public, name, name, now, now + 5 * 365 * @day, true) + + state = %{ + "schema_version" => 1, + "certificate" => Base.encode64(certificate), + "private_key" => Base.encode64(:public_key.der_encode(:PrivateKeyInfo, key)) + } + + case WorkHarbor.Hosts.PrivateFile.create(path, Jason.encode!(state)) do + :ok -> load(dir) + error -> error + end + else + _ -> {:error, :unsafe_identity} + end + rescue + _ -> {:error, :invalid_identity} + end + + def load(dir) do + path = Path.join(dir, "issuer.json") + + if not File.exists?(path) do + {:error, :invalid_identity} + else + with {:ok, bytes} <- WorkHarbor.Hosts.PrivateFile.read(path), + {:ok, + %{"schema_version" => 1, "certificate" => cert64, "private_key" => key64} = state} <- + WorkHarbor.StrictJSON.decode_object(bytes), + true <- map_size(state) == 3, + {:ok, der} <- Base.decode64(cert64), + {:ok, key_der} <- Base.decode64(key64), + true <- Base.encode64(der) == cert64 and Base.encode64(key_der) == key64 do + key = :public_key.der_decode(:PrivateKeyInfo, key_der) + if :public_key.der_encode(:PrivateKeyInfo, key) != key_der, do: throw(:invalid_identity) + public = elem(key, 4) + + public = + if public == :asn1_NOVALUE, + do: elem(:crypto.generate_key(:eddsa, :ed25519, elem(key, 2)), 0), + else: public + + info = inspect_certificate(der) + now = WorkHarbor.Accounts.now() + + if elem(key, 3) != {:namedCurve, @ed} or info.public != public or not root_profile?(info) or + info.not_before > now or info.not_after < now + @day or + info.subject != subject("WorkHarbor client issuer") or + not :public_key.pkix_verify(der, {{:ECPoint, public}, {:namedCurve, @ed}}), + do: throw(:invalid_identity) + + {:ok, + %{ + key: key, + certificate: der, + public: public, + subject: info.subject, + expires_at: info.not_after + }} + else + {:error, :unsafe_identity} = error -> error + _ -> {:error, :invalid_identity} + end + end + rescue + _ -> {:error, :invalid_identity} + catch + _ -> {:error, :invalid_identity} + end + + def valid_issuer?(der) do + info = inspect_certificate(der) + now = WorkHarbor.Accounts.now() + + root_profile?(info) and info.not_before <= now and info.not_after >= now + @day and + :public_key.pkix_verify(der, {{:ECPoint, info.public}, {:namedCurve, @ed}}) + rescue + _ -> false + catch + _ -> false + end + + def issue(issuer, csr, host, mode, now \\ WorkHarbor.Accounts.now()) do + with {:ok, public} <- csr_public(csr, if(mode == :enrollment, do: nil, else: host)), + true <- issuer.expires_at >= now + @day do + {:ok, sign(issuer.key, public, issuer.subject, subject(host), now, now + @day, false)} + else + {:error, _} = e -> e + _ -> {:error, :issuer_unavailable} + end + rescue + _ -> {:error, :invalid_csr} + end + + def csr_public(bytes, host) when is_binary(bytes) and byte_size(bytes) <= 4096 do + {:CertificationRequest, info, {:CertificationRequest_signatureAlgorithm, @ed, :asn1_NOVALUE}, + signature} = request = :public_key.der_decode(:CertificationRequest, bytes) + + if :public_key.der_encode(:CertificationRequest, request) != bytes, do: throw(:invalid_csr) + + {:CertificationRequestInfo, :v1, name, + {:CertificationRequestInfo_subjectPKInfo, + {:CertificationRequestInfo_subjectPKInfo_algorithm, @ed, :asn1_NOVALUE}, public}, []} = info + + expected = + if host, + do: {:rdnSequence, [[{:AttributeTypeAndValue, {2, 5, 4, 3}, {:utf8String, host}}]]}, + else: {:rdnSequence, []} + + if name != expected or byte_size(public) != 32 or + not :crypto.verify( + :eddsa, + :none, + :public_key.der_encode(:CertificationRequestInfo, info), + signature, + [public, :ed25519] + ), + do: throw(:invalid_csr) + + {:ok, public} + rescue + _ -> {:error, :invalid_csr} + catch + _ -> {:error, :invalid_csr} + end + + def csr_public(_, _), do: {:error, :invalid_csr} + + def inspect_certificate(der) do + if not is_binary(der) or byte_size(der) > 4096, do: throw(:invalid_identity) + decoded = :public_key.pkix_decode_cert(der, :otp) + + if :public_key.pkix_encode(:OTPCertificate, decoded, :otp) != der, + do: throw(:invalid_identity) + + value = cert(decoded, :tbsCertificate) + pub = tbs(value, :subjectPublicKeyInfo) + algorithm = spki(pub, :algorithm) + + if public_algorithm(algorithm, :algorithm) != @ed or + public_algorithm(algorithm, :parameters) != :asn1_NOVALUE, + do: throw(:invalid_identity) + + {:ECPoint, public} = spki(pub, :subjectPublicKey) + valid = tbs(value, :validity) + extensions = tbs(value, :extensions) + constraints = find_extension(extensions, {2, 5, 29, 19}) + ids = Enum.map(extensions, &extension(&1, :extnID)) + if length(ids) != length(Enum.uniq(ids)), do: throw(:invalid_identity) + + %{ + basic_present: + constraints != nil and basic(constraints, :pathLenConstraint) == :asn1_NOVALUE, + unknown_critical: + Enum.any?(extensions, fn ext -> + extension(ext, :critical) == true and + extension(ext, :extnID) not in [{2, 5, 29, 19}, {2, 5, 29, 15}, {2, 5, 29, 37}] + end), + public: public, + subject: tbs(value, :subject), + issuer: tbs(value, :issuer), + not_before: seconds(validity(valid, :notBefore)), + not_after: seconds(validity(valid, :notAfter)), + ca: constraints != nil and basic(constraints, :cA) == true, + usage: find_extension(extensions, {2, 5, 29, 15}) || [], + eku: find_extension(extensions, {2, 5, 29, 37}) || [] + } + end + + def valid_leaf?(der, issuer, host, now) do + info = inspect_certificate(der) + + root = inspect_certificate(issuer.certificate) + + root_profile?(root) and root.public == issuer.public and root.subject == issuer.subject and + root.not_after == issuer.expires_at and + :public_key.pkix_verify(issuer.certificate, {{:ECPoint, root.public}, {:namedCurve, @ed}}) and + info.basic_present and not info.unknown_critical and not info.ca and + info.usage == [:digitalSignature] and + info.eku == [{1, 3, 6, 1, 5, 5, 7, 3, 2}] and + info.not_after - info.not_before == @day and info.subject == subject(host) and + info.issuer == issuer.subject and info.not_before <= now and + now < info.not_after and + now < issuer.expires_at and info.not_after <= issuer.expires_at and + :public_key.pkix_verify(der, {{:ECPoint, issuer.public}, {:namedCurve, @ed}}) + rescue + _ -> false + catch + _ -> false + end + + defp root_profile?(info) do + info.ca and info.basic_present and not info.unknown_critical and + info.usage == [:keyCertSign] and info.eku == [] and + info.subject == subject("WorkHarbor client issuer") and + info.not_after - info.not_before == 5 * 365 * @day + end + + def subject(host), + do: {:rdnSequence, [[{:AttributeTypeAndValue, {2, 5, 4, 3}, {:utf8String, host}}]]} + + defp sign(key, public, issuer, subject, start, stop, ca) do + extensions = [ + extension(extnID: {2, 5, 29, 19}, critical: true, extnValue: basic(cA: ca)), + extension( + extnID: {2, 5, 29, 15}, + critical: true, + extnValue: if(ca, do: [:keyCertSign], else: [:digitalSignature]) + ) + ] + + extensions = + if ca, + do: extensions, + else: + extensions ++ + [ + extension( + extnID: {2, 5, 29, 37}, + critical: false, + extnValue: [{1, 3, 6, 1, 5, 5, 7, 3, 2}] + ) + ] + + record = + tbs( + version: :v3, + serialNumber: :binary.decode_unsigned(:crypto.strong_rand_bytes(16)) + 1, + signature: signature(algorithm: @ed), + issuer: issuer, + subject: subject, + validity: validity(notBefore: time(start), notAfter: time(stop)), + subjectPublicKeyInfo: + spki(algorithm: public_algorithm(algorithm: @ed), subjectPublicKey: {:ECPoint, public}), + extensions: extensions + ) + + :public_key.pkix_sign(record, key) + end + + defp find_extension(exts, oid) do + case Enum.find(exts, &(extension(&1, :extnID) == oid)) do + nil -> nil + ext -> extension(ext, :extnValue) + end + end + + defp time(seconds), + do: + {:generalTime, + DateTime.from_unix!(seconds) |> Calendar.strftime("%Y%m%d%H%M%SZ") |> String.to_charlist()} + + defp seconds({kind, chars}) do + s = List.to_string(chars) + + full = + if kind == :utcTime, + do: if(String.slice(s, 0, 2) >= "50", do: "19", else: "20") <> s, + else: s + + [y, m, d, h, n, s] = + for {offset, size} <- [{0, 4}, {4, 2}, {6, 2}, {8, 2}, {10, 2}, {12, 2}], + do: String.slice(full, offset, size) |> String.to_integer() + + :calendar.datetime_to_gregorian_seconds({{y, m, d}, {h, n, s}}) - 62_167_219_200 + end + + defp ensure_dir(dir) do + case File.mkdir(dir) do + :ok -> File.chmod(dir, 0o700) + {:error, :eexist} -> :ok + error -> error + end + end +end diff --git a/apps/control_plane/lib/workharbor/hosts/private_file.ex b/apps/control_plane/lib/workharbor/hosts/private_file.ex new file mode 100644 index 00000000..a9e9056d --- /dev/null +++ b/apps/control_plane/lib/workharbor/hosts/private_file.ex @@ -0,0 +1,125 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Hosts.PrivateFile do + @moduledoc false + import Bitwise + + def traversal(path, missing_final \\ false) when is_binary(path) do + if Path.type(path) != :absolute or Path.expand(path) != path do + {:error, :unsafe_identity} + else + parts = Path.split(path) + + result = + Enum.with_index(parts) + |> Enum.reduce_while("", fn {part, index}, prefix -> + current = if part == "/", do: "/", else: Path.join(prefix, part) + + case File.lstat(current) do + {:ok, %{type: :symlink}} -> + {:halt, {:error, :unsafe_identity}} + + {:ok, _} -> + {:cont, current} + + {:error, :enoent} when missing_final and index == length(parts) - 1 -> + {:cont, current} + + _ -> + {:halt, {:error, :unsafe_identity}} + end + end) + + if is_binary(result), do: :ok, else: result + end + end + + def directory(path) when is_binary(path) do + with :ok <- traversal(path), + {:ok, stat} <- File.lstat(path), + true <- stat.type == :directory and (stat.mode &&& 0o777) == 0o700 and stat.uid == uid(), + do: :ok, + else: (_ -> {:error, :unsafe_identity}) + end + + def directory(_), do: {:error, :unsafe_identity} + + def read(path) do + with :ok <- traversal(path), + :ok <- directory(Path.dirname(path)), + {:ok, stat} <- File.lstat(path), + true <- stat.type == :regular and (stat.mode &&& 0o777) == 0o600 and stat.uid == uid(), + true <- stat.size <= 32768, + {:ok, bytes} <- File.read(path), + do: {:ok, bytes}, + else: (_ -> {:error, :unsafe_identity}) + end + + def create(path, bytes) do + with :ok <- traversal(path, true), :ok <- directory(Path.dirname(path)) do + temporary = path <> "." <> Base.url_encode64(:crypto.strong_rand_bytes(12), padding: false) + + try do + with {:ok, fd} <- + io().open(String.to_charlist(temporary), [:write, :binary, :exclusive, :raw]), + :ok <- File.chmod(temporary, 0o600), + result <- write_close(fd, bytes), + :ok <- result, + :ok <- io().make_link(String.to_charlist(temporary), String.to_charlist(path)), + :ok <- sync_directory(Path.dirname(path)), + do: :ok, + else: ( + {:error, :eexist} -> {:error, :already_initialized} + _ -> {:error, :persistence_failed} + ) + after + File.rm(temporary) + end + end + end + + defp write_close(fd, bytes) do + result = + try do + with :ok <- io().write(fd, bytes), do: io().sync(fd) + rescue + _ -> {:error, :eio} + catch + _, _ -> {:error, :eio} + end + + closed = io().close(fd) + if result == :ok, do: closed, else: result + end + + defp sync_directory(dir) do + with {:ok, fd} <- io().open(String.to_charlist(dir), [:read, :raw, :directory]) do + result = + try do + io().sync(fd) + rescue + _ -> {:error, :eio} + catch + _, _ -> {:error, :eio} + end + + closed = io().close(fd) + if result == :ok, do: closed, else: result + end + end + + # The process-local fault selector is compiled only into test builds. The + # production module has only the native :file implementation, no hook/config. + if Mix.env() == :test do + defp io, do: Process.get({__MODULE__, :test_io}, :file) + else + defp io, do: :file + end + + # OTP has no public geteuid BIF. Fixed POSIX id has no caller-controlled argv, + # shell, secret or network; it verifies the effective service account. + defp uid do + {value, 0} = System.cmd("/usr/bin/id", ["-u"]) + value |> String.trim() |> String.to_integer() + end +end diff --git a/apps/control_plane/lib/workharbor/repo.ex b/apps/control_plane/lib/workharbor/repo.ex new file mode 100644 index 00000000..9195c961 --- /dev/null +++ b/apps/control_plane/lib/workharbor/repo.ex @@ -0,0 +1,5 @@ +defmodule WorkHarbor.Repo do + use Ecto.Repo, + otp_app: :workharbor, + adapter: Ecto.Adapters.SQLite3 +end diff --git a/apps/control_plane/lib/workharbor/strict_json.ex b/apps/control_plane/lib/workharbor/strict_json.ex new file mode 100644 index 00000000..63eb8918 --- /dev/null +++ b/apps/control_plane/lib/workharbor/strict_json.ex @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.StrictJSON do + @moduledoc "Bounded raw JSON to native values, before duplicate keys can collapse." + @max_bytes 1_048_576 + def decode(raw) when is_binary(raw) and byte_size(raw) > @max_bytes, + do: {:error, :input_limit_exceeded} + + def decode(raw) when is_binary(raw) do + with true <- String.valid?(raw) and not String.starts_with?(raw, <<239, 187, 191>>), + :ok <- preflight(raw, false, false, [], 0), + {:ok, value} <- + Jason.decode(raw, objects: :ordered_objects, keys: :strings, strings: :copy) do + validate(value) + {:ok, native(value)} + else + _ -> {:error, :invalid_json} + end + catch + :invalid_json -> {:error, :invalid_json} + end + + def decode(_), do: {:error, :invalid_json} + + def decode_object(raw) do + case decode(raw) do + {:ok, v} when is_map(v) and not is_struct(v) -> {:ok, v} + {:ok, _} -> {:error, :invalid_json} + error -> error + end + end + + # This lexical budget pass deliberately leaves full syntax and scalar escapes + # to locked Jason. It builds neither numbers nor request objects. + defp preflight(<<>>, false, false, [], _), do: :ok + defp preflight(<<>>, _, _, _, _), do: {:error, :invalid_json} + + defp preflight(<<_, rest::binary>>, true, true, stack, _), + do: preflight(rest, true, false, stack, 0) + + defp preflight(<<92, rest::binary>>, true, false, stack, _), + do: preflight(rest, true, true, stack, 0) + + defp preflight(<<34, rest::binary>>, true, false, stack, _), + do: preflight(rest, false, false, stack, 0) + + defp preflight(<<_, rest::binary>>, true, false, stack, _), + do: preflight(rest, true, false, stack, 0) + + defp preflight(<<34, rest::binary>>, false, _, stack, _), + do: preflight(rest, true, false, stack, 0) + + defp preflight(<>, false, _, stack, _) when c in [123, 91] do + if length(stack) >= 128, + do: {:error, :invalid_json}, + else: preflight(rest, false, false, [c | stack], 0) + end + + defp preflight(<>, false, _, [o | stack], _) + when (c == 125 and o == 123) or (c == 93 and o == 91), + do: preflight(rest, false, false, stack, 0) + + defp preflight(<>, false, _, _, _) when c in [125, 93, 46], + do: {:error, :invalid_json} + + defp preflight(<>, false, _, stack, digits) when c in 48..57 do + if digits >= 4300, + do: {:error, :invalid_json}, + else: preflight(rest, false, false, stack, digits + 1) + end + + defp preflight(<>, false, _, _, digits) when c in [101, 69] and digits > 0, + do: {:error, :invalid_json} + + defp preflight(<<_, rest::binary>>, false, _, stack, _), + do: preflight(rest, false, false, stack, 0) + + defp validate(%Jason.OrderedObject{values: values}) do + keys = Enum.map(values, &elem(&1, 0)) + if length(keys) != MapSet.size(MapSet.new(keys)), do: throw(:invalid_json) + Enum.each(values, fn {_, value} -> validate(value) end) + end + + defp validate(v) when is_list(v), do: Enum.each(v, &validate/1) + defp validate(v) when is_float(v), do: throw(:invalid_json) + defp validate(_), do: :ok + + defp native(%Jason.OrderedObject{values: values}), + do: Map.new(values, fn {k, v} -> {k, native(v)} end) + + defp native(v) when is_list(v), do: Enum.map(v, &native/1) + defp native(v) when is_float(v), do: throw(:invalid_json) + defp native(v), do: v +end diff --git a/apps/control_plane/lib/workharbor_web.ex b/apps/control_plane/lib/workharbor_web.ex new file mode 100644 index 00000000..fcdc6b88 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web.ex @@ -0,0 +1,109 @@ +defmodule WorkHarborWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + + This can be used in your application as: + + use WorkHarborWeb, :controller + use WorkHarborWeb, :html + + The definitions below will be executed for every controller, + component, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define additional modules and import + those modules here. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + # Import common connection and controller functions to use in pipelines + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, formats: [:html, :json] + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + # Include general helpers for rendering HTML + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + # HTML escaping functionality + import Phoenix.HTML + # Core UI components + import WorkHarborWeb.CoreComponents + + # Common modules used in templates + alias Phoenix.LiveView.JS + alias WorkHarborWeb.Layouts + + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: WorkHarborWeb.Endpoint, + router: WorkHarborWeb.Router, + statics: WorkHarborWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/live_view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/apps/control_plane/lib/workharbor_web/agent_ingress.ex b/apps/control_plane/lib/workharbor_web/agent_ingress.ex new file mode 100644 index 00000000..653100d3 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/agent_ingress.ex @@ -0,0 +1,184 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.AgentIngress do + @moduledoc "Raw agent transport boundary; browser credentials never prove a host identity." + import Plug.Conn + alias WorkHarbor.{Hosts, StrictJSON} + alias WorkHarborWeb.Security + + @routes [ + {"POST", "/agent/v1/enroll"}, + {"POST", "/agent/v1/identity/renew"}, + {"POST", "/agent/v1/identity/activate"}, + {"GET", "/agent/v1/connect"} + ] + def init(opts), do: opts + + def call(conn, _) do + conn = Security.call(conn, []) + + cond do + conn.halted -> + conn + + mixed_credentials?(conn) -> + Security.deny(conn, 403, :forbidden) + + {conn.method, conn.request_path} not in @routes -> + Security.deny(conn, 400, :invalid_request) + + conn.request_path == "/agent/v1/enroll" -> + enrollment(conn) + + true -> + authenticated(conn) + end + rescue + _ -> Security.deny(conn, 400, :invalid_request) + end + + defp mixed_credentials?(conn), + do: + Enum.any?(conn.req_headers, fn {name, _} -> + name in ["authorization", "cookie", "origin", "x-workharbor-context"] or + String.starts_with?(name, "sec-fetch-") + end) + + defp authenticated(conn) do + mode = if conn.request_path == "/agent/v1/identity/activate", do: :activation, else: :active + # Only the server adapter's verified TLS peer certificate is consulted. + cert = + if elem(conn.adapter, 0) == Bandit.Adapter, do: get_peer_data(conn)[:ssl_cert], else: nil + + case Hosts.resolve_identity(cert, mode) do + {:ok, peer} -> dispatch(conn, peer) + _ -> Security.deny(conn, 401, :unauthorized_host) + end + end + + defp enrollment(conn) do + parsed(conn, ~w(schema_version code csr), fn conn, body -> + with code when is_binary(code) <- body["code"], + {:ok, csr} <- der(body["csr"]), + {:ok, response} <- Hosts.redeem(code, csr) do + json(conn, response) + else + _ -> refuse_body(conn, :invalid_enrollment) + end + end) + end + + defp dispatch(%{request_path: "/agent/v1/identity/renew"} = conn, peer) do + parsed(conn, ~w(schema_version csr), fn conn, body -> + with {:ok, csr} <- der(body["csr"]), {:ok, response} <- Hosts.renew(peer, csr) do + json(conn, response) + else + _ -> refuse_body(conn, :identity_renewal_failed) + end + end) + end + + defp dispatch(%{request_path: "/agent/v1/identity/activate"} = conn, peer) do + parsed(conn, ~w(schema_version), fn conn, _ -> + with :ok <- Hosts.activate(peer), + {:ok, issuer} <- Hosts.issuer(), + true <- Hosts.current_peer?(peer) do + json(conn, %{ + "schema_version" => 1, + "owner_id" => peer.owner_id, + "host_id" => peer.host_id, + "identity_id" => peer.identity_id, + "certificate" => Base.encode64(peer.certificate), + "issuer_certificate" => Base.encode64(issuer.certificate), + "expires_at" => peer.expires_at + }) + else + _ -> refuse_body(conn, :identity_activation_failed) + end + end) + end + + defp dispatch(conn, peer) do + if upgrade?(conn) do + conn + |> WebSockAdapter.upgrade(WorkHarborWeb.AgentSocket, peer, + compress: false, + max_frame_size: 4110, + max_fragmented_message_size: 4096, + timeout: 30000 + ) + |> halt() + else + Security.deny(conn, 400, :invalid_request) + end + end + + defp parsed(conn, keys, callback) do + if get_req_header(conn, "content-type") == ["application/json"] do + case read_body(conn, length: 16384, read_length: 16384, read_timeout: 5000) do + {:ok, raw, conn} when byte_size(raw) <= 16384 -> + case StrictJSON.decode_object(raw) do + {:ok, body} -> + if Enum.sort(Map.keys(body)) == Enum.sort(keys) and body["schema_version"] === 1, + do: callback.(conn, body), + else: refuse_body(conn, :invalid_request) + + _ -> + refuse_body(conn, :invalid_request) + end + + {status, _, conn} when status in [:more, :ok] -> + refuse_body(conn, :invalid_request) + + {:error, _} -> + refuse_body(conn, :invalid_request) + end + else + refuse_body(conn, :invalid_request) + end + end + + defp refuse_body(conn, code), + do: conn |> put_resp_header("connection", "close") |> Security.deny(400, code) + + defp der(value) when is_binary(value) and byte_size(value) <= 5464 do + with {:ok, bytes} <- Base.decode64(value), + true <- byte_size(bytes) <= 4096 and Base.encode64(bytes) == value do + {:ok, bytes} + else + _ -> {:error, :invalid_request} + end + end + + defp der(_), do: {:error, :invalid_request} + + defp json(conn, body), + do: + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(body)) + |> halt() + + defp upgrade?(conn) do + get_req_header(conn, "sec-websocket-version") == ["13"] and + get_req_header(conn, "sec-websocket-extensions") == [] and + get_req_header(conn, "sec-websocket-protocol") == [] and + get_req_header(conn, "connection") |> token?("upgrade") and + get_req_header(conn, "upgrade") |> token?("websocket") and + case get_req_header(conn, "sec-websocket-key") do + [key] when byte_size(key) == 24 -> + case Base.decode64(key) do + {:ok, bytes} -> byte_size(bytes) == 16 and Base.encode64(bytes) == key + _ -> false + end + + _ -> + false + end + end + + defp token?([value], expected) when byte_size(value) <= 128, + do: expected in (value |> String.downcase() |> Plug.Conn.Utils.list()) + + defp token?(_, _), do: false +end diff --git a/apps/control_plane/lib/workharbor_web/agent_log_filter.ex b/apps/control_plane/lib/workharbor_web/agent_log_filter.ex new file mode 100644 index 00000000..7c0269a5 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/agent_log_filter.ex @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.AgentLogFilter do + @moduledoc "Rebuild only agent OTP crash reports before any logger handler receives raw data." + @id :workharbor_agent_transport + def install do + expected = {&__MODULE__.filter/2, []} + + case List.keyfind(:logger.get_primary_config().filters, @id, 0) do + {@id, ^expected} -> + :ok + + nil -> + case :logger.add_primary_filter(@id, expected) do + :ok -> + :ok + + _ -> + if List.keyfind(:logger.get_primary_config().filters, @id, 0) == {@id, expected}, + do: :ok, + else: {:error, :unsafe_agent_logging} + end + + _ -> + {:error, :unsafe_agent_logging} + end + rescue + _ -> {:error, :unsafe_agent_logging} + end + + def filter(%{msg: {:report, report}, meta: meta}, _) when is_map(report) and is_map(meta) do + if agent_report?(report, meta) do + safe = %{domain: [:workharbor, :agent_transport]} + safe = if is_pid(meta[:pid]), do: Map.put(safe, :pid, meta[:pid]), else: safe + safe = if is_integer(meta[:time]), do: Map.put(safe, :time, meta[:time]), else: safe + %{level: :error, msg: {:string, "WorkHarbor agent transport refused"}, meta: safe} + else + :ignore + end + end + + def filter(_, _), do: :ignore + + defp agent_report?(%{label: label} = report, meta) + when label in [{:gen_server, :terminate}, {:proc_lib, :crash}], + do: meta[:workharbor_agent_transport] === true or agent_state?(report) + + defp agent_report?(_, _), do: false + + defp agent_state?(%{ + label: {:gen_server, :terminate}, + state: + {_, %{connection: %Bandit.WebSocket.Connection{websock: WorkHarborWeb.AgentSocket}}} + }), + do: true + + defp agent_state?(_), do: false +end diff --git a/apps/control_plane/lib/workharbor_web/agent_socket.ex b/apps/control_plane/lib/workharbor_web/agent_socket.ex new file mode 100644 index 00000000..2ae68233 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/agent_socket.ex @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.AgentSocket do + @behaviour WebSock + alias WorkHarbor.HostConnections + alias WorkHarbor.HostConnections.Protocol + @impl true + def init(peer) do + Logger.metadata(workharbor_agent_transport: true) + + case HostConnections.open(peer, self()) do + {:ok, {worker, _} = ref} -> + {:ok, %{ref: ref, peer: peer, hello: false, worker_monitor: Process.monitor(worker)}} + + _ -> + {:stop, :normal, 1008, %{ref: nil, peer: peer}} + end + end + + @impl true + def handle_in({raw, [opcode: :text]}, state) when byte_size(raw) <= 4096 do + with {:ok, message} <- Protocol.decode(raw, state.peer.owner_id, state.peer.host_id), + expected = if(state.hello, do: "heartbeat", else: "hello"), + true <- message["kind"] == expected, + {:ok, ack} <- + if(state.hello, + do: HostConnections.heartbeat(state.ref, message), + else: HostConnections.receive_hello(state.ref, message) + ) do + {:push, {:text, Jason.encode!(ack)}, %{state | hello: true}} + else + _ -> {:stop, :normal, 1008, state} + end + end + + def handle_in(_, state), do: {:stop, :normal, 1008, state} + @impl true + def handle_info({:host_connection_close, _}, state), + do: {:stop, :normal, 1008, state} + + def handle_info({:DOWN, monitor, :process, _, _}, %{worker_monitor: monitor} = state), + do: {:stop, :normal, 1008, state} + + def handle_info(_, state), do: {:ok, state} + @impl true + def terminate(_, state), do: HostConnections.disconnect(state.ref) +end diff --git a/apps/control_plane/lib/workharbor_web/auth_controller.ex b/apps/control_plane/lib/workharbor_web/auth_controller.ex new file mode 100644 index 00000000..37de02bd --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/auth_controller.ex @@ -0,0 +1,222 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.AuthController do + use WorkHarborWeb, :controller + import Plug.Conn + alias WorkHarbor.Accounts + alias WorkHarborWeb.{Security, BrowserSession, Body} + + def login(conn, _) do + case Body.parse(conn, :json) do + {:ok, %{"schema_version" => 1, "owner_id" => owner, "owner_secret" => secret} = body, conn} + when map_size(body) == 3 -> + case Accounts.login(owner, secret, "cli_owner") do + {:ok, token, p} -> json(conn, %{token: token, credential: Accounts.status(p)}) + {:error, e} -> error(conn, e) + end + + {:ok, _, conn} -> + error(conn, :invalid_request) + + {:error, e, conn} -> + error(conn, e) + end + end + + def status(conn, _), do: json(conn, %{credential: Accounts.status(conn.assigns.principal)}) + + def logout(conn, _) do + parsed(conn, :json, fn conn, body -> + with true <- map_size(body) == 0, + :ok <- Accounts.revoke(conn.assigns.principal, conn.assigns.principal.credential_id) do + conn = + if conn.assigns.auth_channel == :cookie, + do: configure_session(conn, drop: true), + else: conn + + json(conn, %{ok: true}) + else + {:error, e} -> error(conn, e) + _ -> error(conn, :invalid_request) + end + end) + end + + def issue(conn, _), do: perform_issue(conn, false) + def install(conn, _), do: perform_issue(conn, true) + + defp perform_issue(conn, install?) do + parsed(conn, :json, fn conn, body -> + with true <- map_size(body) == 0, + {:ok, token, p} <- Accounts.issue_dashboard(conn.assigns.principal) do + if install?, + do: installed(conn, token, p), + else: json(conn, %{token: token, credential: Accounts.status(p)}) + else + {:error, e} -> error(conn, e) + _ -> error(conn, :invalid_request) + end + end) + end + + def revoke(conn, _) do + parsed(conn, :json, fn conn, body -> + with %{"credential_id" => id} when map_size(body) == 1 <- body, + :ok <- Accounts.revoke(conn.assigns.principal, id) do + json(conn, %{ok: true}) + else + {:error, e} -> error(conn, e) + _ -> error(conn, :invalid_request) + end + end) + end + + def form(conn, _) do + conn = BrowserSession.initialize_preauth(conn) + conn = Plug.CSRFProtection.call(conn, Plug.CSRFProtection.init([])) + token = Plug.CSRFProtection.get_csrf_token() + + fields = + if conn.request_path == "/login/read-token", + do: + ~s(Read token), + else: + ~s(Owner IDOwner secret) + + html( + conn, + ~s(WorkHarbor sign inWorkHarbor sign in#{fields}Sign in) + ) + end + + def submit(conn, _) do + fields = + if conn.request_path == "/login/read-token", + do: ["dashboard_token", "_csrf_token"], + else: ["owner_id", "owner_secret", "_csrf_token"] + + if Security.own_origin?(conn) and BrowserSession.valid_preauth?(conn) do + parsed(conn, {:form, fields}, fn conn, body -> + with true <- + byte_size(body["_csrf_token"]) == 56 and + BrowserSession.selected_csrf?(conn, body["_csrf_token"]), + {:ok, token, p} <- browser_login(conn, body) do + conn + |> configure_session(drop: true) + |> BrowserSession.install(conn.private.auth_target, token, p.expires_at) + |> redirect_to(if(conn.private.auth_target == :owner, do: "/owner", else: "/dashboard")) + else + {:error, e} -> error(conn, e) + _ -> error(conn, :forbidden) + end + end) + else + error(conn, :forbidden) + end + end + + defp browser_login(%{request_path: "/login/read-token"}, body) do + case Accounts.authenticate(body["dashboard_token"], "dashboard") do + {:ok, p} -> {:ok, body["dashboard_token"], p} + e -> e + end + end + + defp browser_login(conn, body), + do: + Accounts.login( + body["owner_id"], + body["owner_secret"], + if(conn.private.auth_target == :owner, do: "owner_browser", else: "dashboard") + ) + + def page(conn, _) do + conn = Plug.CSRFProtection.call(conn, Plug.CSRFProtection.init([])) + token = Plug.CSRFProtection.get_csrf_token() + owner? = conn.private.auth_target == :owner + title = if owner?, do: "Owner management", else: "Read-only dashboard" + + controls = + if owner? do + control_form("/owner/dashboard", token, "Open read-only dashboard") <> + control_form("/owner/logout", token, "Sign out of owner management") + else + control_form("/login/dashboard/clear", token, "Clear dashboard sign-in") + end + + html( + conn, + ~s(WorkHarbor#{title}#{controls}) + ) + end + + def clear(conn, _), + do: + browser_form(conn, fn conn -> + conn |> configure_session(drop: true) |> redirect_to("/login/read-token") + end) + + def browser_logout(conn, _) do + browser_form(conn, fn conn -> + case Accounts.revoke(conn.assigns.principal, conn.assigns.principal.credential_id) do + :ok -> conn |> configure_session(drop: true) |> redirect_to("/login/owner") + {:error, e} -> error(conn, e) + end + end) + end + + def browser_dashboard(conn, _) do + browser_form(conn, fn conn -> + case Accounts.issue_dashboard(conn.assigns.principal) do + {:ok, token, p} -> installed(conn, token, p) + {:error, e} -> error(conn, e) + end + end) + end + + defp browser_form(conn, work) do + if Security.own_origin?(conn) do + parsed(conn, {:form, ["_csrf_token"]}, fn conn, body -> + if BrowserSession.selected_csrf?(conn, body["_csrf_token"]), + do: work.(conn), + else: error(conn, :forbidden) + end) + else + error(conn, :forbidden) + end + end + + defp parsed(conn, type, work) do + case Body.parse(conn, type) do + {:ok, body, updated} -> work.(updated, body) + {:error, e, updated} -> error(updated, e) + end + end + + defp installed(conn, token, p), + do: + conn |> BrowserSession.install(:dashboard, token, p.expires_at) |> redirect_to("/dashboard") + + defp redirect_to(conn, path), + do: conn |> put_resp_header("location", path) |> send_resp(303, "") + + defp control_form(path, csrf, label), + do: + ~s(#{label}) + + def error(conn, e), + do: + Security.deny( + conn, + case e do + :unauthenticated -> 401 + :forbidden -> 403 + :not_found -> 404 + :input_limit_exceeded -> 413 + :unsupported_media_type -> 415 + :persistence_failed -> 503 + _ -> 400 + end, + e + ) +end diff --git a/apps/control_plane/lib/workharbor_web/authority.ex b/apps/control_plane/lib/workharbor_web/authority.ex new file mode 100644 index 00000000..4c5f22b6 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/authority.ex @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.Authority do + @moduledoc "Strict shared TLS authority/origin spelling and comparison." + def parse(raw) when is_binary(raw) and byte_size(raw) <= 320 do + case Regex.run(~r/\A(\[[0-9a-fA-F:.]+\]|[a-zA-Z0-9.-]+)(?::([0-9]{1,5}))?\z/, raw, + capture: :all_but_first + ) do + [host] -> effective(host, 443) + [host, ""] -> effective(host, 443) + [host, port] -> effective(host, String.to_integer(port)) + _ -> :error + end + end + + def parse(_), do: :error + + def effective(host, port) when is_binary(host) and is_integer(port) and port in 1..65535 do + if byte_size(host) <= 320 and String.valid?(host) and Regex.match?(~r/\A[\x21-\x7e]+\z/, host) do + normalized(host, port) + else + :error + end + end + + def effective(_, _), do: :error + + defp normalized(host, port) do + host = String.downcase(host) + + cond do + String.starts_with?(host, "[") -> + if String.ends_with?(host, "]"), + do: ipv6(String.slice(host, 1, byte_size(host) - 2), port), + else: :error + + String.contains?(host, ":") -> + ipv6(host, port) + + byte_size(host) > 253 or host == "" -> + :error + + Enum.all?( + String.split(host, "."), + &Regex.match?(~r/\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/, &1) + ) -> + {:ok, {host, port}} + + true -> + :error + end + end + + def origin(value) when is_binary(value) do + case Regex.run(~r/\Ahttps:\/\/(.+)\z/i, value, capture: :all_but_first) do + [authority] -> parse(authority) + _ -> :error + end + end + + def origin(_), do: :error + + defp ipv6(host, port) do + case :inet.parse_address(String.to_charlist(host)) do + {:ok, tuple} when tuple_size(tuple) == 8 -> + {:ok, {tuple |> :inet.ntoa() |> to_string(), port}} + + _ -> + :error + end + end +end diff --git a/apps/control_plane/lib/workharbor_web/components/core_components.ex b/apps/control_plane/lib/workharbor_web/components/core_components.ex new file mode 100644 index 00000000..e88ae658 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/components/core_components.ex @@ -0,0 +1,501 @@ +defmodule WorkHarborWeb.CoreComponents do + @moduledoc """ + Provides core UI components. + + At first glance, this module may seem daunting, but its goal is to provide + core building blocks for your application, such as tables, forms, and + inputs. The components consist mostly of markup and are well-documented + with doc strings and declarative assigns. You may customize and style + them in any way you want, based on your application growth and needs. + + The foundation for styling is Tailwind CSS, a utility-first CSS framework, + augmented with daisyUI, a Tailwind CSS plugin that provides UI components + and themes. Here are useful references: + + * [daisyUI](https://daisyui.com/docs/intro/) - a good place to get + started and see the available components. + + * [Tailwind CSS](https://tailwindcss.com) - the foundational framework + we build on. You will use it for layout, sizing, flexbox, grid, and + spacing. + + * [Heroicons](https://heroicons.com) - see `icon/1` for usage. + + * [Phoenix.Component](https://phoenix-live-view.hexdocs.pm/Phoenix.Component.html) - + the component system used by Phoenix. Some components, such as `<.link>` + and `<.form>`, are defined there. + + """ + use Phoenix.Component + + alias Phoenix.LiveView.JS + + @doc """ + Renders flash notices. + + ## Examples + + <.flash kind={:info} flash={@flash} /> + <.flash + id="welcome-back" + kind={:info} + phx-mounted={show("#welcome-back") |> JS.remove_attribute("hidden")} + hidden + > + Welcome Back! + + """ + attr :id, :string, doc: "the optional id of flash container" + attr :flash, :map, default: %{}, doc: "the map of flash messages to display" + attr :title, :string, default: nil + attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup" + attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" + + slot :inner_block, doc: "the optional inner block that renders the flash message" + + def flash(assigns) do + assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end) + + ~H""" + hide("##{@id}")} + role="alert" + class="toast toast-top toast-end z-50" + {@rest} + > + + <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" /> + <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" /> + + {@title} + {msg} + + + + <.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" /> + + + + """ + end + + @doc """ + Renders a button with navigation support. + + ## Examples + + <.button>Send! + <.button phx-click="go" variant="primary">Send! + <.button navigate={~p"/"}>Home + """ + attr :rest, :global, include: ~w(href navigate patch method download name value disabled) + attr :class, :any + attr :variant, :string, values: ~w(primary) + slot :inner_block, required: true + + def button(%{rest: rest} = assigns) do + variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"} + + assigns = + assign_new(assigns, :class, fn -> + ["btn", Map.fetch!(variants, assigns[:variant])] + end) + + if rest[:href] || rest[:navigate] || rest[:patch] do + ~H""" + <.link class={@class} {@rest}> + {render_slot(@inner_block)} + + """ + else + ~H""" + + {render_slot(@inner_block)} + + """ + end + end + + @doc """ + Renders an input with label and error messages. + + A `Phoenix.HTML.FormField` may be passed as argument, + which is used to retrieve the input name, id, and values. + Otherwise all attributes may be passed explicitly. + + ## Types + + This function accepts all HTML input types, considering that: + + * You may also set `type="select"` to render a `` tag + + * `type="checkbox"` is used exclusively to render boolean values + + * For live file uploads, see `Phoenix.Component.live_file_input/1` + + See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input + for more information. Unsupported types, such as radio, are best + written directly in your templates. + + ## Examples + + ```heex + <.input field={@form[:email]} type="email" /> + <.input name="my-input" errors={["oh no!"]} /> + ``` + + ## Select type + + When using `type="select"`, you must pass the `options` and optionally + a `value` to mark which option should be preselected. + + ```heex + <.input field={@form[:user_type]} type="select" options={["Admin": "admin", "User": "user"]} /> + ``` + + For more information on what kind of data can be passed to `options` see + [`options_for_select`](https://phoenix-html.hexdocs.pm/Phoenix.HTML.Form.html#options_for_select/2). + """ + attr :id, :any, default: nil + attr :name, :any + attr :label, :string, default: nil + attr :value, :any + + attr :type, :string, + default: "text", + values: ~w(checkbox color date datetime-local email file month number password + search select tel text textarea time url week hidden) + + attr :field, Phoenix.HTML.FormField, + doc: "a form field struct retrieved from the form, for example: @form[:email]" + + attr :errors, :list, default: [] + attr :checked, :boolean, doc: "the checked flag for checkbox inputs" + attr :prompt, :string, default: nil, doc: "the prompt for select inputs" + attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2" + attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs" + attr :class, :any, default: nil, doc: "the input class to use over defaults" + attr :error_class, :any, default: nil, doc: "the input error class to use over defaults" + + attr :rest, :global, + include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength + multiple pattern placeholder readonly required rows size step) + + def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do + errors = if Phoenix.Component.used_input?(field), do: field.errors, else: [] + + assigns + |> assign(field: nil, id: assigns.id || field.id) + |> assign(:errors, Enum.map(errors, &translate_error(&1))) + |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end) + |> assign_new(:value, fn -> field.value end) + |> input() + end + + def input(%{type: "hidden"} = assigns) do + ~H""" + + """ + end + + def input(%{type: "checkbox"} = assigns) do + assigns = + assign_new(assigns, :checked, fn -> + Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) + end) + + ~H""" + + + + + {@label} + + + <.error :for={msg <- @errors}>{msg} + + """ + end + + def input(%{type: "select"} = assigns) do + ~H""" + + + {@label} + + {@prompt} + {Phoenix.HTML.Form.options_for_select(@options, @value)} + + + <.error :for={msg <- @errors}>{msg} + + """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" + + + {@label} + {Phoenix.HTML.Form.normalize_value("textarea", @value)} + + <.error :for={msg <- @errors}>{msg} + + """ + end + + # All other inputs text, datetime-local, url, password, etc. are handled here... + def input(assigns) do + ~H""" + + + {@label} + + + <.error :for={msg <- @errors}>{msg} + + """ + end + + # Helper used by inputs to generate form errors + defp error(assigns) do + ~H""" + + <.icon name="hero-exclamation-circle" class="size-5" /> + {render_slot(@inner_block)} + + """ + end + + @doc """ + Renders a header with title. + """ + slot :inner_block, required: true + slot :subtitle + slot :actions + + def header(assigns) do + ~H""" + + + + {render_slot(@inner_block)} + + + {render_slot(@subtitle)} + + + {render_slot(@actions)} + + """ + end + + @doc """ + Renders a table with generic styling. + + ## Examples + + <.table id="users" rows={@users}> + <:col :let={user} label="id">{user.id} + <:col :let={user} label="username">{user.username} + + """ + attr :id, :string, required: true + attr :rows, :list, required: true + attr :row_id, :any, default: nil, doc: "the function for generating the row id" + attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" + + attr :row_item, :any, + default: &Function.identity/1, + doc: "the function for mapping each row before calling the :col and :action slots" + + slot :col, required: true do + attr :label, :string + end + + slot :action, doc: "the slot for showing user actions in the last table column" + + def table(assigns) do + assigns = + with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do + assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) + end + + ~H""" + + + + {col[:label]} + + Actions + + + + + + + {render_slot(col, @row_item.(row))} + + + + <%= for action <- @action do %> + {render_slot(action, @row_item.(row))} + <% end %> + + + + + + """ + end + + @doc """ + Renders a data list. + + ## Examples + + <.list> + <:item title="Title">{@post.title} + <:item title="Views">{@post.views} + + """ + slot :item, required: true do + attr :title, :string, required: true + end + + def list(assigns) do + ~H""" + + + + {item.title} + {render_slot(item)} + + + + """ + end + + @doc """ + Renders a [Heroicon](https://heroicons.com). + + Heroicons come in three styles β outline, solid, and mini. + By default, the outline style is used, but solid and mini may + be applied by using the `-solid` and `-mini` suffix. + + You can customize the size and colors of the icons by setting + width, height, and background color classes. + + Icons are extracted from the `deps/heroicons` directory and bundled within + your compiled app.css by the plugin in `assets/vendor/heroicons.js`. + + ## Examples + + <.icon name="hero-x-mark" /> + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + """ + attr :name, :string, required: true + attr :class, :any, default: "size-4" + + def icon(%{name: "hero-" <> _} = assigns) do + ~H""" + + """ + end + + ## JS Commands + + def show(js \\ %JS{}, selector) do + JS.show(js, + to: selector, + time: 300, + transition: + {"transition-all ease-out duration-300", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", + "opacity-100 translate-y-0 sm:scale-100"} + ) + end + + def hide(js \\ %JS{}, selector) do + JS.hide(js, + to: selector, + time: 200, + transition: + {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} + ) + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # You can make use of gettext to translate error messages by + # uncommenting and adjusting the following code: + + # if count = opts[:count] do + # Gettext.dngettext(WorkHarborWeb.Gettext, "errors", msg, msg, count, opts) + # else + # Gettext.dgettext(WorkHarborWeb.Gettext, "errors", msg, opts) + # end + + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) + end) + end + + @doc """ + Translates the errors for a field from a keyword list of errors. + """ + def translate_errors(errors, field) when is_list(errors) do + for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) + end +end diff --git a/apps/control_plane/lib/workharbor_web/components/layouts.ex b/apps/control_plane/lib/workharbor_web/components/layouts.ex new file mode 100644 index 00000000..2c8eefc4 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/components/layouts.ex @@ -0,0 +1,160 @@ +defmodule WorkHarborWeb.Layouts do + @moduledoc """ + This module holds layouts and related functionality + used by your application. + """ + use WorkHarborWeb, :html + + # Embed all files in layouts/* within this module. + # The default root.html.heex file contains the HTML + # skeleton of your application, namely HTML headers + # and other static content. + embed_templates "layouts/*" + + @doc """ + Renders your app layout. + + This function is typically invoked from every template, + and it often contains your application menu, sidebar, + or similar. + + ## Examples + + + Content + + + """ + attr :flash, :map, required: true, doc: "the map of flash messages" + + attr :current_scope, :map, + default: nil, + doc: "the current [scope](https://phoenix.hexdocs.pm/scopes.html)" + + slot :inner_block, required: true + + def app(assigns) do + ~H""" + + + + + v{Application.spec(:phoenix, :vsn)} + + + + + + Website + + + GitHub + + + <.theme_toggle /> + + + + Get Started → + + + + + + + + + {render_slot(@inner_block)} + + + + <.flash_group flash={@flash} /> + """ + end + + @doc """ + Shows the flash group with standard titles and content. + + ## Examples + + <.flash_group flash={@flash} /> + """ + attr :flash, :map, required: true, doc: "the map of flash messages" + attr :id, :string, default: "flash-group", doc: "the optional id of flash container" + + def flash_group(assigns) do + ~H""" + + <.flash kind={:info} flash={@flash} /> + <.flash kind={:error} flash={@flash} /> + + <.flash + id="client-error" + kind={:error} + title="We can't find the internet" + phx-disconnected={ + show(".phx-client-error #client-error") + |> JS.remove_attribute("hidden", to: ".phx-client-error #client-error") + } + phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})} + hidden + > + Attempting to reconnect + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + + + <.flash + id="server-error" + kind={:error} + title="Something went wrong!" + phx-disconnected={ + show(".phx-server-error #server-error") + |> JS.remove_attribute("hidden", to: ".phx-server-error #server-error") + } + phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})} + hidden + > + Attempting to reconnect + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + + + """ + end + + @doc """ + Provides dark vs light theme toggle based on themes defined in app.css. + + See in root.html.heex which applies the theme before page load. + """ + def theme_toggle(assigns) do + ~H""" + + + + + <.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" /> + + + + <.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" /> + + + + <.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" /> + + + """ + end +end diff --git a/apps/control_plane/lib/workharbor_web/components/layouts/root.html.heex b/apps/control_plane/lib/workharbor_web/components/layouts/root.html.heex new file mode 100644 index 00000000..a8430ea8 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/components/layouts/root.html.heex @@ -0,0 +1,43 @@ + + + + + + + <.live_title default="WorkHarbor" suffix=" Β· Phoenix Framework" phx-no-format>{assigns[:page_title]} + + + + + + {@inner_content} + + diff --git a/apps/control_plane/lib/workharbor_web/controllers/error_html.ex b/apps/control_plane/lib/workharbor_web/controllers/error_html.ex new file mode 100644 index 00000000..bc86b62f --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/controllers/error_html.ex @@ -0,0 +1,24 @@ +defmodule WorkHarborWeb.ErrorHTML do + @moduledoc """ + This module is invoked by your endpoint in case of errors on HTML requests. + + See config/config.exs. + """ + use WorkHarborWeb, :html + + # If you want to customize your error pages, + # uncomment the embed_templates/1 call below + # and add pages to the error directory: + # + # * lib/workharbor_web/controllers/error_html/404.html.heex + # * lib/workharbor_web/controllers/error_html/500.html.heex + # + # embed_templates "error_html/*" + + # The default is to render a plain text page based on + # the template name. For example, "404.html" becomes + # "Not Found". + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/apps/control_plane/lib/workharbor_web/controllers/error_json.ex b/apps/control_plane/lib/workharbor_web/controllers/error_json.ex new file mode 100644 index 00000000..2021e44e --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule WorkHarborWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/apps/control_plane/lib/workharbor_web/controllers/page_controller.ex b/apps/control_plane/lib/workharbor_web/controllers/page_controller.ex new file mode 100644 index 00000000..e5ae4085 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/controllers/page_controller.ex @@ -0,0 +1,7 @@ +defmodule WorkHarborWeb.PageController do + use WorkHarborWeb, :controller + + def home(conn, _params) do + render(conn, :home) + end +end diff --git a/apps/control_plane/lib/workharbor_web/controllers/page_html.ex b/apps/control_plane/lib/workharbor_web/controllers/page_html.ex new file mode 100644 index 00000000..3c432a77 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/controllers/page_html.ex @@ -0,0 +1,10 @@ +defmodule WorkHarborWeb.PageHTML do + @moduledoc """ + This module contains pages rendered by PageController. + + See the `page_html` directory for all templates available. + """ + use WorkHarborWeb, :html + + embed_templates "page_html/*" +end diff --git a/apps/control_plane/lib/workharbor_web/controllers/page_html/home.html.heex b/apps/control_plane/lib/workharbor_web/controllers/page_html/home.html.heex new file mode 100644 index 00000000..5a550fd0 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/controllers/page_html/home.html.heex @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + Phoenix Framework + + v{Application.spec(:phoenix, :vsn)} + + + + + + + Peace of mind from prototype to production. + + + Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. + + + + + + + + + + + + Guides & Docs + + + + + + + + + Source Code + + + + + + + + + + Changelog + + + + + + + + + + Discuss on the Elixir Forum + + + + + + + + Join our Discord server + + + + + + + + Join us on Slack + + + + + + + + Deploy your application + + + + + + + diff --git a/apps/control_plane/lib/workharbor_web/endpoint.ex b/apps/control_plane/lib/workharbor_web/endpoint.ex new file mode 100644 index 00000000..086725ba --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/endpoint.ex @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :workharbor + + socket "/owner/live", WorkHarborWeb.OwnerSocket, + websocket: [ + connect_info: [session: {WorkHarborWeb.BrowserSession, :options, [:owner]}], + check_csrf: true, + check_origin: {WorkHarborWeb.Security, :socket_origin, []} + ], + longpoll: false + + socket "/dashboard/live", WorkHarborWeb.DashboardSocket, + websocket: [ + connect_info: [session: {WorkHarborWeb.BrowserSession, :options, [:dashboard]}], + check_csrf: true, + check_origin: {WorkHarborWeb.Security, :socket_origin, []} + ], + longpoll: false + + # Phoenix inserts socket_dispatch before user plugs. Wrap its entry explicitly. + def call(conn, opts) do + admission = + if String.starts_with?(conn.request_path, "/agent"), + do: WorkHarborWeb.AgentIngress.call(conn, []), + else: WorkHarborWeb.Security.call(conn, []) + + case admission do + %{halted: true} = conn -> conn + conn -> super(conn, opts) + end + end + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + plug WorkHarborWeb.BrowserSession + plug WorkHarborWeb.Router +end diff --git a/apps/control_plane/lib/workharbor_web/host_controller.ex b/apps/control_plane/lib/workharbor_web/host_controller.ex new file mode 100644 index 00000000..37e72956 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/host_controller.ex @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.HostController do + use WorkHarborWeb, :controller + alias WorkHarbor.Hosts + alias WorkHarborWeb.{Body, Security} + + def issue(conn, _) do + parsed(conn, fn conn, body -> + with %{"schema_version" => 1, "host_id" => host} <- body, + true <- + map_size(body) == 2 and (is_nil(host) or WorkHarbor.Foundation.Request.uuid?(host)), + {:ok, issued} <- Hosts.issue_enrollment(conn.assigns.principal, host) do + json(conn, Map.put(issued, :schema_version, 1)) + else + _ -> refuse(conn, :host_enrollment_failed) + end + end) + end + + def revoke(conn, %{"id" => host}) do + parsed(conn, fn conn, body -> + with %{"schema_version" => 1} <- body, + true <- map_size(body) == 1, + :ok <- Hosts.revoke(conn.assigns.principal, host) do + json(conn, %{schema_version: 1, host_id: host, revoked: true}) + else + _ -> refuse(conn, :host_revocation_failed) + end + end) + end + + defp parsed(conn, callback) do + case Body.parse(conn, :json) do + {:ok, body, conn} -> callback.(conn, body) + {:error, _, conn} -> refuse(conn, :invalid_request) + end + end + + defp refuse(conn, code), + do: conn |> Plug.Conn.put_resp_header("connection", "close") |> Security.deny(400, code) + + def status(conn, %{"id" => host}) do + case Hosts.status(conn.assigns.principal, host) do + {:ok, status} -> + connection = + case WorkHarbor.HostConnections.host_state(host) do + {:ok, "connected"} -> "connected" + _ -> "unknown" + end + + json(conn, Map.merge(status, %{schema_version: 1, connection: connection})) + + _ -> + Security.deny(conn, 404, :not_found) + end + end +end diff --git a/apps/control_plane/lib/workharbor_web/login_form.ex b/apps/control_plane/lib/workharbor_web/login_form.ex new file mode 100644 index 00000000..7e616361 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/login_form.ex @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.LoginForm do + @moduledoc false + def decode(raw, _) when is_binary(raw) and byte_size(raw) > 4096, + do: {:error, :input_limit_exceeded} + + def decode(raw, fields) when is_binary(raw) do + pairs = String.split(raw, "&") + + with true <- length(pairs) <= 3, + {:ok, values} <- decode_pairs(pairs, fields, []) do + {:ok, Map.new(values)} + else + _ -> {:error, :invalid_form} + end + end + + def decode(_, _), do: {:error, :invalid_form} + + defp decode_pairs([], fields, values) do + if Enum.sort(Enum.map(values, &elem(&1, 0))) == Enum.sort(fields), + do: {:ok, values}, + else: :error + end + + defp decode_pairs([pair | rest], fields, values) do + with [k, v] <- String.split(pair, "=", parts: 2), + true <- percent?(k) and percent?(v), + key <- URI.decode_www_form(k), + value <- URI.decode_www_form(v), + true <- String.valid?(key) and String.valid?(value) and value != "", + true <- key in fields and not List.keymember?(values, key, 0) do + decode_pairs(rest, fields, [{key, value} | values]) + else + _ -> :error + end + end + + defp percent?(<<>>), do: true + + defp percent?(<<37, a, b, rest::binary>>) + when a in ~c"0123456789abcdefABCDEF" and b in ~c"0123456789abcdefABCDEF", + do: percent?(rest) + + defp percent?(<<37, _::binary>>), do: false + defp percent?(<<_, rest::binary>>), do: percent?(rest) +end diff --git a/apps/control_plane/lib/workharbor_web/owner_socket.ex b/apps/control_plane/lib/workharbor_web/owner_socket.ex new file mode 100644 index 00000000..e8adda62 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/owner_socket.ex @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.OwnerSocket do + use Phoenix.LiveView.Socket + + def connect(_params, socket, info) do + with %{"credential" => token} <- info[:session], + {:ok, p} <- WorkHarbor.Accounts.authenticate(token, "owner_browser"), + :ok <- WorkHarbor.Accounts.authorize(p, :owner_page) do + {:ok, Phoenix.Socket.assign(socket, :principal, p)} + else + _ -> :error + end + end +end + +defmodule WorkHarborWeb.DashboardSocket do + use Phoenix.LiveView.Socket + + def connect(_params, socket, info) do + with %{"credential" => token} <- info[:session], + {:ok, p} <- WorkHarbor.Accounts.authenticate(token, "dashboard"), + :ok <- WorkHarbor.Accounts.authorize(p, :dashboard_page) do + {:ok, Phoenix.Socket.assign(socket, :principal, p)} + else + _ -> :error + end + end +end diff --git a/apps/control_plane/lib/workharbor_web/plugs/require_owner.ex b/apps/control_plane/lib/workharbor_web/plugs/require_owner.ex new file mode 100644 index 00000000..e75c99f5 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/plugs/require_owner.ex @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.Plugs.RequireOwner do + @moduledoc false + import Plug.Conn + alias WorkHarbor.{Accounts} + alias WorkHarborWeb.{Security, BrowserSession} + def init(opts), do: opts + + def call(conn, action) do + conn = fetch_session(conn) + + with {:ok, p, channel} <- principal(conn), + :ok <- Accounts.authorize(p, action), + :ok <- channel_admission(conn, channel, action) do + conn |> assign(:principal, p) |> assign(:auth_channel, channel) + else + {:error, :forbidden} -> Security.deny(conn, 403, :forbidden) + {:error, :context} -> Security.deny(conn, 400, :invalid_context) + _ -> Security.deny(conn, 401, :unauthenticated) + end + end + + def principal(conn) do + cookie? = BrowserSession.auth_cookies?(conn) + + case get_req_header(conn, "authorization") do + ["Bearer " <> token] -> + with {:ok, p} <- Accounts.authenticate(token), + true <- p.kind in ["cli_owner", "dashboard"] do + if cookie?, do: known_mixed(p), else: {:ok, p, :bearer} + else + _ -> {:error, :unauthenticated} + end + + [] -> + target = conn.private.auth_target + + cond do + is_nil(target) and cookie? -> + {:error, :context} + + is_nil(target) -> + {:error, :unauthenticated} + + true -> + case Accounts.authenticate( + get_session(conn, "credential"), + if(target == :owner, do: "owner_browser", else: "dashboard") + ) do + {:ok, p} -> {:ok, p, :cookie} + e -> e + end + end + + _ -> + {:error, :unauthenticated} + end + end + + def primary_admission(conn) do + conn = fetch_session(conn) + + case principal(conn) do + {:ok, %{kind: "dashboard"} = p, _} -> + Accounts.authorize(p, :primary_login) + Security.deny(conn, 403, :forbidden) + + {:error, :forbidden} -> + Security.deny(conn, 403, :forbidden) + + _ -> + if get_req_header(conn, "authorization") != [] or BrowserSession.auth_cookies?(conn) or + Enum.any?( + ["origin", "sec-fetch-site", "x-workharbor-context"], + &(get_req_header(conn, &1) != []) + ), do: Security.deny(conn, 400, :invalid_channel), else: conn + end + end + + def cli_secret(conn, _) do + if conn.assigns.auth_channel == :bearer and conn.assigns.principal.kind == "cli_owner" do + conn + else + Accounts.deny(conn.assigns.principal, :issue_dashboard) + Security.deny(conn, 403, :forbidden) + end + end + + def cookie_csrf(conn) do + if conn.assigns[:auth_channel] == :cookie do + origin = Security.own_origin?(conn) + + allowed = + origin or + (conn.method in ["GET", "HEAD"] and get_req_header(conn, "origin") == [] and + get_req_header(conn, "sec-fetch-site") == ["same-origin"]) + + token = + case get_req_header(conn, "x-csrf-token") do + [v] -> v + _ -> nil + end + + if allowed and BrowserSession.selected_csrf?(conn, token), + do: conn, + else: Security.deny(conn, 403, :forbidden) + else + conn + end + end + + defp known_mixed(%{kind: "dashboard"} = p), do: Accounts.authorize(p, :primary_login) + defp known_mixed(_), do: {:error, :unauthenticated} + + defp channel_admission(conn, :bearer, _) do + if not BrowserSession.auth_cookies?(conn) and + Enum.all?( + ["origin", "sec-fetch-site", "x-workharbor-context"], + &(get_req_header(conn, &1) == []) + ), do: :ok, else: {:error, :forbidden} + end + + defp channel_admission(conn, :cookie, action) when action in [:owner_page, :dashboard_page] do + if get_req_header(conn, "sec-fetch-site") in [[], ["none"], ["same-origin"]], + do: :ok, + else: {:error, :forbidden} + end + + defp channel_admission(_, :cookie, _), do: :ok +end diff --git a/apps/control_plane/lib/workharbor_web/router.ex b/apps/control_plane/lib/workharbor_web/router.ex new file mode 100644 index 00000000..2f7b38be --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/router.ex @@ -0,0 +1,152 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.Router do + use WorkHarborWeb, :router + alias WorkHarborWeb.Plugs.RequireOwner + + pipeline :session do + plug :fetch_session + end + + pipeline :primary do + plug :primary_admission + end + + pipeline :read do + plug RequireOwner, :status + plug :api_csrf + end + + pipeline :logout do + plug RequireOwner, :logout + plug :api_csrf + end + + pipeline :revoke do + plug RequireOwner, :revoke + plug :api_csrf + end + + pipeline :issue do + plug RequireOwner, :issue_dashboard + plug :api_csrf + end + + pipeline :issue_secret do + plug RequireOwner, :issue_dashboard + plug :cli_secret + end + + pipeline :browser_logout do + plug RequireOwner, :logout + end + + pipeline :browser_issue do + plug RequireOwner, :issue_dashboard + end + + pipeline :owner do + plug RequireOwner, :owner_page + end + + pipeline :dashboard do + plug RequireOwner, :dashboard_page + end + + pipeline :host_enrollment do + plug RequireOwner, :host_enrollment_issue + plug :api_csrf + end + + pipeline :host_revocation do + plug RequireOwner, :host_revoke + plug :api_csrf + end + + pipeline :host_status do + plug RequireOwner, :host_status + plug :api_csrf + end + + scope "/api/v1/hosts", WorkHarborWeb do + pipe_through [:session, :host_enrollment] + post "/enrollments", HostController, :issue, log: false + end + + scope "/api/v1/hosts", WorkHarborWeb do + pipe_through [:session, :host_revocation] + post "/:id/revoke", HostController, :revoke, log: false + end + + scope "/api/v1/hosts", WorkHarborWeb do + pipe_through [:session, :host_status] + get "/:id", HostController, :status, log: false + end + + scope "/", WorkHarborWeb do + pipe_through :session + get "/login/owner", AuthController, :form, log: false + get "/login/dashboard", AuthController, :form, log: false + get "/login/read-token", AuthController, :form, log: false + post "/login/owner", AuthController, :submit, log: false + post "/login/dashboard", AuthController, :submit, log: false + post "/login/read-token", AuthController, :submit, log: false + post "/login/dashboard/clear", AuthController, :clear, log: false + end + + scope "/api/v1/auth", WorkHarborWeb do + pipe_through [:session, :primary] + post "/login", AuthController, :login, log: false + end + + scope "/api/v1/auth", WorkHarborWeb do + pipe_through [:session, :read] + get "/status", AuthController, :status, log: false + end + + scope "/api/v1/auth", WorkHarborWeb do + pipe_through [:session, :logout] + post "/logout", AuthController, :logout, log: false + end + + scope "/api/v1/auth", WorkHarborWeb do + pipe_through [:session, :revoke] + post "/revoke", AuthController, :revoke, log: false + end + + scope "/api/v1/auth", WorkHarborWeb do + pipe_through [:session, :issue_secret] + post "/dashboard-credentials", AuthController, :issue, log: false + end + + scope "/api/v1/auth", WorkHarborWeb do + pipe_through [:session, :issue] + post "/dashboard-install", AuthController, :install, log: false + end + + scope "/owner", WorkHarborWeb do + pipe_through [:session, :browser_logout] + post "/logout", AuthController, :browser_logout, log: false + end + + scope "/owner", WorkHarborWeb do + pipe_through [:session, :browser_issue] + post "/dashboard", AuthController, :browser_dashboard, log: false + end + + scope "/", WorkHarborWeb do + pipe_through [:session, :owner] + get "/", AuthController, :page + get "/owner", AuthController, :page + end + + scope "/", WorkHarborWeb do + pipe_through [:session, :dashboard] + get "/dashboard", AuthController, :page + end + + defp cli_secret(conn, opts), do: RequireOwner.cli_secret(conn, opts) + + defp primary_admission(conn, _), do: RequireOwner.primary_admission(conn) + defp api_csrf(conn, _), do: RequireOwner.cookie_csrf(conn) +end diff --git a/apps/control_plane/lib/workharbor_web/runtime_security.ex b/apps/control_plane/lib/workharbor_web/runtime_security.ex new file mode 100644 index 00000000..b50d1b56 --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/runtime_security.ex @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.RuntimeSecurity do + @moduledoc "Fail closed before an authenticated listener is started." + import Bitwise + + @demo [ + "9kwSZZ3zqgPLCQHHHC7Q9KYXXSVttc/mmuUT4ZmFiV1BGre/cOXzP7jNrsMeH4N4", + "4XBsLMaWd4lPmLPEtg7KgIs8803lJErmCoNVLqsywrCNiiRCgTgMU4U5WdOI+TAl" + ] + @https_keys ~w(ip port certfile keyfile http_1_options http_2_options)a + @peer_keys ~w(cacerts verify fail_if_no_peer_cert)a + + def identity_config(config, nil), do: {:ok, config} + + def identity_config(config, opts) do + with true <- Keyword.keyword?(opts) and Keyword.keys(opts) == [:issuer_dir], + {:ok, issuer} <- WorkHarbor.Hosts.Issuer.load(opts[:issuer_dir]) do + tls = Keyword.get(config, :https, []) + + if Keyword.has_key?(tls, :thousand_island_options) or + Enum.any?(@peer_keys, &Keyword.has_key?(tls, &1)), + do: throw(:invalid_identity) + + {:ok, + Keyword.put( + config, + :https, + tls ++ + [ + thousand_island_options: [ + transport_options: [ + cacerts: [issuer.certificate], + verify: :verify_peer, + fail_if_no_peer_cert: false + ] + ] + ] + )} + else + _ -> {:error, :invalid_identity} + end + rescue + _ -> {:error, :invalid_identity} + catch + _ -> {:error, :invalid_identity} + end + + def validate!(config, owner_http) do + # Phoenix treats other truthy values as active and missing values can inherit + # serve_endpoints. Require an explicit, unambiguous activation decision. + unless Keyword.get_values(config, :server) in [[true], [false]], + do: raise(ArgumentError, "unsafe authentication server configuration") + + if config[:server] == true do + key = config[:secret_key_base] + tls = config[:https] + + valid = + is_binary(key) and byte_size(key) >= 64 and key not in @demo and + config[:debug_errors] == false and config[:code_reloader] == false and + config[:http] in [nil, false] and config[:force_ssl] in [nil, false] and + http1_profile?(config, tls) and + is_tuple(tls[:ip]) and is_integer(tls[:port]) and tls[:port] in 1..65535 and + regular?(tls[:certfile]) and private?(tls[:keyfile]) and + valid_origin?(owner_http, tls[:port]) + + if not valid, do: raise(ArgumentError, "unsafe authentication server configuration") + end + + :ok + rescue + _ -> raise ArgumentError, "unsafe authentication server configuration" + end + + # GHSA-69m6-wcv4-rwcf: locked Bandit is mitigated, not patched. Keep this + # current profile narrow: lower-layer handler/ALPN overrides bypass switches. + # Optional client roots below are the exact reviewed T008 nested exception. + defp http1_profile?(config, tls) do + config[:adapter] == Bandit.PhoenixAdapter and Keyword.keyword?(tls) and + tls_keys?(tls) and + tls[:http_1_options] == [enabled: true] and tls[:http_2_options] == [enabled: false] + end + + defp tls_keys?(tls) do + keys = Enum.sort(Keyword.keys(tls)) + + keys == Enum.sort(@https_keys) or + (keys == Enum.sort(@https_keys ++ [:thousand_island_options]) and + peer_options?(tls[:thousand_island_options])) + end + + defp peer_options?(opts) do + Keyword.keyword?(opts) and Keyword.keys(opts) == [:transport_options] and + case opts[:transport_options] do + values when is_list(values) -> + Keyword.keyword?(values) and Enum.sort(Keyword.keys(values)) == Enum.sort(@peer_keys) and + values[:verify] == :verify_peer and values[:fail_if_no_peer_cert] == false and + case values[:cacerts] do + [der] when is_binary(der) -> WorkHarbor.Hosts.Issuer.valid_issuer?(der) + _ -> false + end + + _ -> + false + end + end + + defp regular?(p), do: is_binary(p) and Path.type(p) == :absolute and File.regular?(p) + + defp private?(p) do + with true <- regular?(p), {:ok, s} <- File.stat(p), do: (s.mode &&& 0o077) == 0 + end + + defp valid_origin?(owner_http, listener_port) do + with {:ok, {_, port} = authority} <- WorkHarborWeb.Authority.origin(owner_http[:origin]), + true <- port == listener_port, + authorities when is_list(authorities) <- owner_http[:authorities], + true <- + Enum.all?(authorities, fn + {host, p} -> match?({:ok, _}, WorkHarborWeb.Authority.effective(host, p)) + _ -> false + end) do + Enum.any?(authorities, fn {host, p} -> + WorkHarborWeb.Authority.effective(host, p) == {:ok, authority} + end) + else + _ -> false + end + end +end diff --git a/apps/control_plane/lib/workharbor_web/security.ex b/apps/control_plane/lib/workharbor_web/security.ex new file mode 100644 index 00000000..35453bab --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/security.ex @@ -0,0 +1,347 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.Security do + @moduledoc false + import Plug.Conn + def init(opts), do: opts + + def call(conn, _) do + config = Application.fetch_env!(:workharbor, :owner_http) + + with true <- conn.scheme == :https, + true <- authority?(conn, Keyword.fetch!(config, :authorities)), + true <- origin?(conn), + {:ok, target, store} <- context(conn), + true <- socket_origin?(conn), + :ok <- canonical_path(conn), + :ok <- query_admission(conn), + true <- upgrade_headers?(conn) do + conn |> put_private(:auth_target, target) |> put_private(:auth_store, store) |> headers() + else + {:error, :invalid_request} -> deny(conn, 400, :invalid_request) + _ -> deny(conn, 403, :forbidden) + end + end + + def headers(conn) do + conn + |> put_resp_header("cache-control", "no-store") + |> put_resp_header("x-content-type-options", "nosniff") + |> put_resp_header("x-frame-options", "DENY") + |> put_resp_header( + "content-security-policy", + "default-src 'none'; script-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'" + ) + |> put_resp_header("referrer-policy", "no-referrer") + |> put_resp_header("strict-transport-security", "max-age=31536000") + end + + def deny(conn, status, code), + do: + conn + |> headers() + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(%{error: %{code: code}})) + |> halt() + + def own_origin?(conn) do + expected = + WorkHarborWeb.Authority.origin( + Keyword.fetch!(Application.fetch_env!(:workharbor, :owner_http), :origin) + ) + + case get_req_header(conn, "origin") do + [origin] -> expected != :error and WorkHarborWeb.Authority.origin(origin) == expected + _ -> false + end + end + + def socket_origin(uri) do + WorkHarborWeb.Authority.origin(URI.to_string(uri)) == + WorkHarborWeb.Authority.origin( + Keyword.fetch!(Application.fetch_env!(:workharbor, :owner_http), :origin) + ) + end + + defp origin?(conn), do: get_req_header(conn, "origin") == [] or own_origin?(conn) + + defp socket?(conn), + do: conn.request_path in ["/owner/live/websocket", "/dashboard/live/websocket"] + + defp socket_origin?(conn), do: not socket?(conn) or own_origin?(conn) + + defp authority?(conn, allowed) do + with {:ok, effective} <- WorkHarborWeb.Authority.effective(conn.host, conn.port), + true <- + Enum.any?(allowed, fn {host, port} -> + WorkHarborWeb.Authority.effective(host, port) == {:ok, effective} + end) do + case get_req_header(conn, "host") do + [] -> get_http_protocol(conn) == :"HTTP/2" + [h] -> WorkHarborWeb.Authority.parse(h) == {:ok, effective} + _ -> false + end + else + _ -> false + end + end + + defp canonical_path(conn) do + path = conn.request_path + + if String.contains?(path, ["%", "//", "\\"]) or (path != "/" and String.ends_with?(path, "/")), + do: {:error, :invalid_request}, + else: :ok + end + + # Fixed current auth/control routes have no query parameters. Socket transport + # allows only its two non-credential parameters, decoded before duplicate loss. + defp query_admission(conn) do + cond do + socket?(conn) -> + case WorkHarborWeb.LoginForm.decode(conn.query_string, ["_csrf_token", "vsn"]) do + {:ok, %{"_csrf_token" => csrf, "vsn" => "2.0.0"}} when byte_size(csrf) == 56 -> :ok + _ -> {:error, :invalid_request} + end + + conn.query_string == "" -> + :ok + + true -> + {:error, :invalid_request} + end + end + + # The stock adapter reports malformed upgrade header values in its exception. + # Admit only known header shapes so those diagnostics cannot echo credentials. + defp upgrade_headers?(conn) do + not socket?(conn) or + (get_req_header(conn, "sec-websocket-version") == ["13"] and + header_token?(conn, "connection", "upgrade") and + header_token?(conn, "upgrade", "websocket") and + case get_req_header(conn, "sec-websocket-key") do + [key] -> + case Base.decode64(key) do + {:ok, bytes} -> byte_size(bytes) == 16 and Base.encode64(bytes) == key + _ -> false + end + + _ -> + false + end) + end + + defp header_token?(conn, name, expected) do + case get_req_header(conn, name) do + [value] -> expected in (value |> String.downcase() |> Plug.Conn.Utils.list()) + _ -> false + end + end + + defp context(conn) do + target = + cond do + conn.request_path == "/" -> + :owner + + conn.request_path == "/login/dashboard/clear" -> + :dashboard + + String.starts_with?(conn.request_path, "/login/owner") -> + :owner + + String.starts_with?(conn.request_path, "/login/dashboard") or + conn.request_path == "/login/read-token" -> + :dashboard + + String.starts_with?(conn.request_path, "/owner") -> + :owner + + String.starts_with?(conn.request_path, "/dashboard") -> + :dashboard + + true -> + nil + end + + values = get_req_header(conn, "x-workharbor-context") + + header = + case values do + [] -> nil + ["owner"] -> :owner + ["dashboard"] -> :dashboard + _ -> :invalid + end + + cond do + header == :invalid -> + {:error, :context} + + target && header && target != header -> + {:error, :context} + + true -> + target = target || header + + store = + if String.starts_with?(conn.request_path, "/login/") and + conn.request_path != "/login/dashboard/clear", + do: :preauth, + else: target || :preauth + + {:ok, target, store} + end + end +end + +defmodule WorkHarborWeb.BrowserSession do + @moduledoc false + import Plug.Conn + + @names %{ + owner: "__Host-workharbor_owner", + dashboard: "__Host-workharbor_dashboard", + preauth: "__Host-workharbor_login" + } + def options(context) do + [ + store: :cookie, + key: Map.fetch!(@names, context), + signing_salt: "workharbor-#{context}-sign-v1", + encryption_salt: "workharbor-#{context}-encrypt-v1", + secure: true, + http_only: true, + same_site: "Strict", + path: "/", + max_age: if(context == :preauth, do: 600, else: 28_800), + log: false + ] + end + + def init(opts), do: opts + + def call(conn, _) do + conn = register_before_send(conn, &cap_cookie/1) + Plug.Session.call(conn, Plug.Session.init(options(conn.private.auth_store))) + end + + defp cap_cookie(conn) do + store = conn.private.auth_store + key = Map.fetch!(@names, store) + + case {store, conn.assigns[:principal], conn.resp_cookies[key]} do + {s, %{expires_at: expiry}, %{max_age: age} = cookie} + when s in [:owner, :dashboard] and age > 0 -> + cookie = Map.put(cookie, :max_age, min(age, max(expiry - WorkHarbor.Accounts.now(), 0))) + %{conn | resp_cookies: Map.put(conn.resp_cookies, key, cookie)} + + _ -> + conn + end + end + + def install(conn, context, token, expiry) do + opts = options(context) + + value = + Plug.Session.COOKIE.put(conn, nil, %{"credential" => token}, Plug.Session.COOKIE.init(opts)) + + cookie_opts = + Keyword.take(opts, [:secure, :http_only, :same_site, :path]) + |> Keyword.put(:max_age, max(expiry - WorkHarbor.Accounts.now(), 0)) + + put_resp_cookie(conn, Keyword.fetch!(opts, :key), value, cookie_opts) + end + + def auth_cookies?(conn) do + conn = fetch_cookies(conn) + Enum.any?([:owner, :dashboard], &Map.has_key?(conn.req_cookies, @names[&1])) + end + + def selected_csrf?(conn, token) do + state = Plug.CSRFProtection.dump_state_from_session(get_session(conn, "_csrf_token")) + + is_binary(state) and is_binary(token) and + Plug.CSRFProtection.valid_state_and_csrf_token?(state, token) + end + + def initialize_preauth(conn) do + now = WorkHarbor.Accounts.now() + + conn + |> clear_session() + |> configure_session(renew: true) + |> put_session("issued_at", now) + |> put_session("expires_at", now + 600) + end + + def valid_preauth?(conn) do + issued = get_session(conn, "issued_at") + expiry = get_session(conn, "expires_at") + now = WorkHarbor.Accounts.now() + + is_integer(issued) and is_integer(expiry) and expiry - issued == 600 and issued <= now and + expiry > now + end +end + +defmodule WorkHarborWeb.Body do + @moduledoc false + import Plug.Conn + + def parse(conn, :json) do + parse( + conn, + 1_048_576, + ~r/^application\/json(?:;\s*charset=(?:utf-8|"utf-8"))?$/i, + &WorkHarbor.StrictJSON.decode_object/1 + ) + end + + def parse(conn, {:form, fields}) do + parse( + conn, + 4096, + ~r/^application\/x-www-form-urlencoded(?:;\s*charset=(?:utf-8|"utf-8"))?$/i, + &WorkHarborWeb.LoginForm.decode(&1, fields) + ) + end + + defp parse(conn, limit, type, decoder) do + with [header] <- get_req_header(conn, "content-type"), true <- Regex.match?(type, header) do + case read(conn, limit, [], 0, System.monotonic_time(:millisecond) + 15_000) do + {:ok, body, conn} -> + case decoder.(body) do + {:ok, value} -> {:ok, value, conn} + {:error, e} -> {:error, e, conn} + end + + error -> + error + end + else + _ -> {:error, :unsupported_media_type, conn} + end + end + + defp read(conn, limit, parts, size, deadline) do + if System.monotonic_time(:millisecond) > deadline do + {:error, :invalid_request, conn} + else + case read_body(conn, length: 16_384, read_length: 16_384, read_timeout: 5000) do + {status, data, conn} when status in [:more, :ok] -> + size = size + byte_size(data) + + cond do + size > limit -> {:error, :input_limit_exceeded, conn} + status == :ok -> {:ok, IO.iodata_to_binary(Enum.reverse([data | parts])), conn} + true -> read(conn, limit, [data | parts], size, deadline) + end + + _ -> + {:error, :invalid_request, conn} + end + end + end +end diff --git a/apps/control_plane/lib/workharbor_web/telemetry.ex b/apps/control_plane/lib/workharbor_web/telemetry.ex new file mode 100644 index 00000000..06b4256c --- /dev/null +++ b/apps/control_plane/lib/workharbor_web/telemetry.ex @@ -0,0 +1,93 @@ +defmodule WorkHarborWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://telemetry-metrics.hexdocs.pm + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + sum("phoenix.socket_drain.count"), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("workharbor.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("workharbor.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("workharbor.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("workharbor.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("workharbor.repo.query.idle_time", + unit: {:native, :millisecond}, + description: + "The time the connection spent waiting before being checked out for the query" + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {WorkHarborWeb, :count_users, []} + ] + end +end diff --git a/apps/control_plane/mix.exs b/apps/control_plane/mix.exs new file mode 100644 index 00000000..6c6a1c94 --- /dev/null +++ b/apps/control_plane/mix.exs @@ -0,0 +1,96 @@ +defmodule WorkHarbor.MixProject do + use Mix.Project + + def project do + [ + app: :workharbor, + version: "0.1.0", + elixir: "== 1.20.4", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps(), + compilers: [:phoenix_live_view] ++ Mix.compilers(), + listeners: [Phoenix.CodeReloader] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {WorkHarbor.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + def cli do + [ + preferred_envs: [precommit: :test] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "== 1.8.15"}, + {:phoenix_ecto, "~> 4.5"}, + {:ecto_sql, "~> 3.13"}, + {:ecto_sqlite3, ">= 0.0.0"}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 1.2.0"}, + {:lazy_html, ">= 0.1.0", only: :test}, + {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.5", runtime: Mix.env() == :dev}, + {:heroicons, + github: "tailwindlabs/heroicons", + tag: "v2.2.0", + sparse: "optimized", + app: false, + compile: false, + depth: 1}, + {:daisyui, + github: "saadeghi/daisyui", + tag: "v5.5.20", + sparse: "packages/bundle", + app: false, + compile: false, + depth: 1}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:jason, "~> 1.2"}, + {:bandit, "~> 1.5"} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["compile", "tailwind workharbor", "esbuild workharbor"], + "assets.deploy": [ + "tailwind workharbor --minify", + "esbuild workharbor --minify", + "phx.digest" + ], + precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"] + ] + end +end diff --git a/apps/control_plane/mix.lock b/apps/control_plane/mix.lock new file mode 100644 index 00000000..97bc8e96 --- /dev/null +++ b/apps/control_plane/mix.lock @@ -0,0 +1,36 @@ +%{ + "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "daisyui": {:git, "https://github.com/saadeghi/daisyui.git", "22ecff57f2c391b80a75617325748cf4d13fdf47", [tag: "v5.5.20", sparse: "packages/bundle", depth: 1]}, + "db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"}, + "decimal": {:hex, :decimal, "3.2.0", "f1235f05d841a211fda78a08fdb9777f2ff51a71f18455233fae186ff76187c6", [:mix], [], "hexpm", "190bca80580ce61aa69bb0eacadd7677710205c770225c2c2c07e32a5c31b234"}, + "ecto": {:hex, :ecto, "3.14.2", "99db28a864293a789c970651de711e3cae184291e0e7ea1166c54055ac41c1f3", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25d60b8c816a07d19d85b80bdf60978bd8b102209dda198d768cd7c6745339a6"}, + "ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"}, + "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.25.0", "309898d694b17a8ca8cd88d648d3a7fe8479b04ea6831a18d97d56ed3541031b", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.14", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "7da65c7af38dccf228320db32f93ae49650b0afdd850a09fd2fb191554b3faf5"}, + "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, + "exqlite": {:hex, :exqlite, "0.42.0", "3effa63f9fa365893fc318c597a9dc1253aa5acf1abd138660efacf830317f6d", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "01e1cce89dd6be4966acc87d426d7ba93455820138fe1564f422a6478419f1f7"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, + "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "lazy_html": {:hex, :lazy_html, "0.1.13", "860ea816f5bc7936d1ab5ccebc388f9d1031ba9c35ed81a4db9a1c3a50737704", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "9a8405d6785fe6f8423b86e0ec5f21806ef79941fe853eac3d14fbbb173c34e9"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "phoenix": {:hex, :phoenix, "1.8.15", "dcdb304113660ec97e1579458824d3c34b5d2eed07a0fdf25a836a9515b63146", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "7b83ed6b3d544f24a29277eab7f051be38b76f390bb511bb6ddb7ec6e8e05b95"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.7.0", "fb1e429f6d8778ce3a6962debdc5e555428a05a6e7b058d6dbad13d281a2c31f", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "dc9f44271aa6fc4ab7797f2aa374ba096ef2c87520586280eb095626b7387a68"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.12", "35848150bbab579e9d0aff79525269d7c0801a4f61232b84f635c11222cee88f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "656810d716e3369545dd63981196a5d68b77fdb253afe02ef0c6fa14cfd8dc2b"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.4.1", "2ff3e7ba8b432120989870997b9adaefb76e1f63805554ab0b9e948987981f4b", [:mix], [], "hexpm", "352fba7a87bf61395b6e97da66944ad9b1bfe5459e5981039f60d68270bd9700"}, + "phoenix_template": {:hex, :phoenix_template, "1.1.0", "b329582281b1e00e4dc664afd60ef5b489cbb356923caa3b1e00abe8651b9a18", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "eba70070de79b2c3501ef205a74a69f98ab352f3785aa15da9ed161f9fe0fd5d"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, + "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, + "tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.2.0", "7632c19c01d88d8aaca5da1a0e8912f5af39b79e7c08a2c253aeb3c14c2c957e", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "71dde12fc29b58b9c77ec17ec319109e5ca848d010fc1965ed4463bba1837c07"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, +} diff --git a/apps/control_plane/priv/repo/migrations/.formatter.exs b/apps/control_plane/priv/repo/migrations/.formatter.exs new file mode 100644 index 00000000..49f9151e --- /dev/null +++ b/apps/control_plane/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/apps/control_plane/priv/repo/migrations/20261014000000_owner_foundation.exs b/apps/control_plane/priv/repo/migrations/20261014000000_owner_foundation.exs new file mode 100644 index 00000000..733e1876 --- /dev/null +++ b/apps/control_plane/priv/repo/migrations/20261014000000_owner_foundation.exs @@ -0,0 +1,204 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Repo.Migrations.OwnerFoundation do + use Ecto.Migration + + def up do + execute(""" + CREATE TABLE owners (id TEXT PRIMARY KEY NOT NULL, display_name TEXT NOT NULL, inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL) + """) + + execute(""" + CREATE TABLE hosts (id TEXT PRIMARY KEY NOT NULL, owner_id TEXT NOT NULL REFERENCES owners(id), identity_fingerprint TEXT, + capabilities TEXT NOT NULL DEFAULT '[]', last_seen TEXT, lease_expires_at TEXT, inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(id,owner_id)) + """) + + execute(""" + CREATE TABLE workspaces (id TEXT PRIMARY KEY NOT NULL, owner_id TEXT NOT NULL REFERENCES owners(id), host_id TEXT NOT NULL, + desired_state TEXT NOT NULL CHECK(desired_state IN ('running','stopped')), + intent_version INTEGER NOT NULL DEFAULT 0 CHECK(typeof(intent_version)='integer' AND intent_version>=0), + last_runtime_generation INTEGER NOT NULL DEFAULT 0 CHECK(typeof(last_runtime_generation)='integer' AND last_runtime_generation>=0), + requested_config TEXT NOT NULL, inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(id,owner_id), UNIQUE(id,host_id), + FOREIGN KEY(host_id,owner_id) REFERENCES hosts(id,owner_id)) + """) + + execute(""" + CREATE TABLE operations (id TEXT PRIMARY KEY NOT NULL, owner_id TEXT NOT NULL REFERENCES owners(id), workspace_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, action TEXT NOT NULL CHECK(action IN ('create','start','stop','rebuild','remove_runtime')), + request_version INTEGER NOT NULL CHECK(request_version=1), request_bytes BLOB NOT NULL, request_hash TEXT NOT NULL, + intent_version INTEGER NOT NULL CHECK(typeof(intent_version)='integer' AND intent_version>0), + status TEXT NOT NULL CHECK(status IN ('queued','executing','succeeded','failed')), + execution_config_snapshot TEXT, terminal_result TEXT, inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(id,workspace_id), UNIQUE(id,intent_version), UNIQUE(owner_id,workspace_id,idempotency_key), UNIQUE(workspace_id,intent_version), + FOREIGN KEY(workspace_id,owner_id) REFERENCES workspaces(id,owner_id), + CHECK((status IN ('queued','executing') AND terminal_result IS NULL) OR (status IN ('succeeded','failed') AND terminal_result IS NOT NULL))) + """) + + execute(""" + CREATE TABLE persistent_storage (id TEXT PRIMARY KEY NOT NULL, workspace_id TEXT NOT NULL, host_id TEXT NOT NULL, + reservation_operation_id TEXT NOT NULL UNIQUE, provider_handle TEXT, deleted_at TEXT, inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(workspace_id,host_id) REFERENCES workspaces(id,host_id), + FOREIGN KEY(reservation_operation_id,workspace_id) REFERENCES operations(id,workspace_id)) + """) + + execute(""" + CREATE TABLE runtime_instances (id TEXT PRIMARY KEY NOT NULL, workspace_id TEXT NOT NULL, host_id TEXT NOT NULL, + reservation_operation_id TEXT NOT NULL UNIQUE, + generation INTEGER NOT NULL CHECK(typeof(generation)='integer' AND generation>0), provider_instance_id TEXT, + observed_state TEXT NOT NULL CHECK(observed_state IN ('pending','provisioning','running','stopping','stopped','failed','unknown')), + effective_config TEXT, is_current INTEGER NOT NULL DEFAULT 0 CHECK(is_current IN (0,1)), deleted_at TEXT, inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(id,workspace_id), UNIQUE(id,generation), UNIQUE(workspace_id,generation), + FOREIGN KEY(workspace_id,host_id) REFERENCES workspaces(id,host_id), + FOREIGN KEY(reservation_operation_id,workspace_id) REFERENCES operations(id,workspace_id), + CHECK(deleted_at IS NULL OR is_current=0)) + """) + + execute(""" + CREATE TABLE operation_runtime_bindings (id TEXT PRIMARY KEY NOT NULL, operation_id TEXT NOT NULL, workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('target','candidate')), inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(operation_id,role), + FOREIGN KEY(operation_id,workspace_id) REFERENCES operations(id,workspace_id), + FOREIGN KEY(runtime_id,workspace_id) REFERENCES runtime_instances(id,workspace_id)) + """) + + execute(""" + CREATE TABLE audit_events (id TEXT PRIMARY KEY NOT NULL, owner_id TEXT NOT NULL REFERENCES owners(id), host_id TEXT, + workspace_id TEXT, operation_id TEXT, event TEXT NOT NULL, action TEXT NOT NULL, intent_version INTEGER, + channel TEXT NOT NULL CHECK(channel IN ('cli','browser','internal')), outcome TEXT NOT NULL, code TEXT, + runtime_id TEXT REFERENCES runtime_instances(id), runtime_generation INTEGER, metadata TEXT NOT NULL DEFAULT '{}', inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(operation_id,event), FOREIGN KEY(host_id,owner_id) REFERENCES hosts(id,owner_id), + FOREIGN KEY(workspace_id,owner_id) REFERENCES workspaces(id,owner_id), + FOREIGN KEY(operation_id,workspace_id) REFERENCES operations(id,workspace_id), + FOREIGN KEY(workspace_id,host_id) REFERENCES workspaces(id,host_id), + FOREIGN KEY(operation_id,intent_version) REFERENCES operations(id,intent_version), + FOREIGN KEY(runtime_id,workspace_id) REFERENCES runtime_instances(id,workspace_id), + FOREIGN KEY(runtime_id,runtime_generation) REFERENCES runtime_instances(id,generation), + CHECK((runtime_id IS NULL AND runtime_generation IS NULL) OR (runtime_id IS NOT NULL AND runtime_generation IS NOT NULL AND workspace_id IS NOT NULL)), + CHECK(workspace_id IS NULL OR host_id IS NOT NULL), + CHECK(operation_id IS NULL OR (workspace_id IS NOT NULL AND host_id IS NOT NULL AND intent_version IS NOT NULL))) + """) + + execute( + "CREATE UNIQUE INDEX one_unfinished_operation ON operations(workspace_id) WHERE status IN ('queued','executing')" + ) + + execute( + "CREATE UNIQUE INDEX one_current_runtime ON runtime_instances(workspace_id) WHERE is_current=1" + ) + + execute( + "CREATE UNIQUE INDEX one_project_storage ON persistent_storage(workspace_id) WHERE deleted_at IS NULL" + ) + + execute( + "CREATE TRIGGER workspace_scope_immutable BEFORE UPDATE ON workspaces WHEN NEW.owner_id IS NOT OLD.owner_id OR NEW.host_id IS NOT OLD.host_id BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER host_owner_immutable BEFORE UPDATE ON hosts WHEN NEW.owner_id IS NOT OLD.owner_id BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER operation_identity_immutable BEFORE UPDATE ON operations WHEN NEW.owner_id IS NOT OLD.owner_id OR NEW.workspace_id IS NOT OLD.workspace_id OR NEW.idempotency_key IS NOT OLD.idempotency_key OR NEW.action IS NOT OLD.action OR NEW.request_version IS NOT OLD.request_version OR NEW.request_bytes IS NOT OLD.request_bytes OR NEW.request_hash IS NOT OLD.request_hash OR NEW.intent_version IS NOT OLD.intent_version OR NEW.execution_config_snapshot IS NOT OLD.execution_config_snapshot BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER operation_terminal_immutable BEFORE UPDATE ON operations WHEN OLD.status IN ('succeeded','failed') BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER operation_transition BEFORE UPDATE ON operations WHEN NOT ((OLD.status='queued' AND NEW.status IN ('queued','executing','failed')) OR (OLD.status='executing' AND NEW.status IN ('executing','succeeded','failed')) OR OLD.status IN ('succeeded','failed')) BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER runtime_identity_immutable BEFORE UPDATE ON runtime_instances WHEN NEW.workspace_id IS NOT OLD.workspace_id OR NEW.host_id IS NOT OLD.host_id OR NEW.reservation_operation_id IS NOT OLD.reservation_operation_id OR NEW.generation IS NOT OLD.generation BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER runtime_provider_immutable BEFORE UPDATE ON runtime_instances WHEN OLD.provider_instance_id IS NOT NULL AND NEW.provider_instance_id IS NOT OLD.provider_instance_id BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER runtime_effective_immutable BEFORE UPDATE ON runtime_instances WHEN OLD.effective_config IS NOT NULL AND NEW.effective_config IS NOT OLD.effective_config BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER runtime_tombstone_immutable BEFORE UPDATE ON runtime_instances WHEN OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NOT OLD.deleted_at BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER storage_identity_immutable BEFORE UPDATE ON persistent_storage WHEN NEW.workspace_id IS NOT OLD.workspace_id OR NEW.host_id IS NOT OLD.host_id OR NEW.reservation_operation_id IS NOT OLD.reservation_operation_id BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER storage_handle_immutable BEFORE UPDATE ON persistent_storage WHEN OLD.provider_handle IS NOT NULL AND NEW.provider_handle IS NOT OLD.provider_handle BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER storage_tombstone_immutable BEFORE UPDATE ON persistent_storage WHEN OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NOT OLD.deleted_at BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER workspaces_retained BEFORE DELETE ON workspaces WHEN 1 BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER operations_retained BEFORE DELETE ON operations WHEN 1 BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER runtime_instances_retained BEFORE DELETE ON runtime_instances WHEN 1 BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER persistent_storage_retained BEFORE DELETE ON persistent_storage WHEN 1 BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER operation_runtime_bindings_retained BEFORE DELETE ON operation_runtime_bindings WHEN 1 BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER audit_events_retained BEFORE DELETE ON audit_events WHEN 1 BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER bindings_immutable BEFORE UPDATE ON operation_runtime_bindings WHEN 1 BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER audit_immutable BEFORE UPDATE ON audit_events WHEN 1 BEGIN SELECT RAISE(ABORT, 'foundation invariant'); END" + ) + + execute( + "CREATE TRIGGER candidate_binding_owner BEFORE INSERT ON operation_runtime_bindings WHEN NEW.role='candidate' AND NOT EXISTS (SELECT 1 FROM runtime_instances r WHERE r.id=NEW.runtime_id AND r.reservation_operation_id=NEW.operation_id AND r.workspace_id=NEW.workspace_id) BEGIN SELECT RAISE(ABORT, 'candidate reservation mismatch'); END" + ) + end + + def down do + execute("DROP TRIGGER candidate_binding_owner") + execute("DROP TRIGGER workspace_scope_immutable") + execute("DROP TRIGGER host_owner_immutable") + execute("DROP TRIGGER operation_identity_immutable") + execute("DROP TRIGGER operation_terminal_immutable") + execute("DROP TRIGGER operation_transition") + execute("DROP TRIGGER runtime_identity_immutable") + execute("DROP TRIGGER runtime_provider_immutable") + execute("DROP TRIGGER runtime_effective_immutable") + execute("DROP TRIGGER runtime_tombstone_immutable") + execute("DROP TRIGGER storage_identity_immutable") + execute("DROP TRIGGER storage_handle_immutable") + execute("DROP TRIGGER storage_tombstone_immutable") + execute("DROP TRIGGER workspaces_retained") + execute("DROP TRIGGER operations_retained") + execute("DROP TRIGGER runtime_instances_retained") + execute("DROP TRIGGER persistent_storage_retained") + execute("DROP TRIGGER operation_runtime_bindings_retained") + execute("DROP TRIGGER audit_events_retained") + execute("DROP TRIGGER bindings_immutable") + execute("DROP TRIGGER audit_immutable") + execute("DROP TABLE audit_events") + execute("DROP TABLE operation_runtime_bindings") + execute("DROP TABLE runtime_instances") + execute("DROP TABLE persistent_storage") + execute("DROP TABLE operations") + execute("DROP TABLE workspaces") + execute("DROP TABLE hosts") + execute("DROP TABLE owners") + end +end diff --git a/apps/control_plane/priv/repo/migrations/20261014010000_owner_authentication.exs b/apps/control_plane/priv/repo/migrations/20261014010000_owner_authentication.exs new file mode 100644 index 00000000..ae7ec276 --- /dev/null +++ b/apps/control_plane/priv/repo/migrations/20261014010000_owner_authentication.exs @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Repo.Migrations.OwnerAuthentication do + use Ecto.Migration + + def up do + execute( + "CREATE TABLE owner_authenticators (id INTEGER PRIMARY KEY CHECK(id=1), owner_id TEXT NOT NULL UNIQUE REFERENCES owners(id), secret_hash BLOB NOT NULL CHECK(length(secret_hash)=32))" + ) + + execute( + "CREATE TABLE owner_credentials (id TEXT PRIMARY KEY NOT NULL, owner_id TEXT NOT NULL REFERENCES owners(id), token_hash BLOB NOT NULL UNIQUE CHECK(length(token_hash)=32), kind TEXT NOT NULL CHECK(kind IN ('cli_owner','owner_browser','dashboard')), issued_at INTEGER NOT NULL CHECK(typeof(issued_at)='integer'), expires_at INTEGER NOT NULL CHECK(typeof(expires_at)='integer' AND expires_at>issued_at), revoked_at INTEGER CHECK(revoked_at IS NULL OR typeof(revoked_at)='integer'), CHECK(expires_at-issued_at<=CASE WHEN kind='cli_owner' THEN 2592000 ELSE 28800 END))" + ) + + execute( + "CREATE TRIGGER owner_authenticator_immutable BEFORE UPDATE ON owner_authenticators BEGIN SELECT RAISE(ABORT,'authentication invariant'); END" + ) + + execute( + "CREATE TRIGGER owner_credential_identity_immutable BEFORE UPDATE ON owner_credentials WHEN NEW.id IS NOT OLD.id OR NEW.owner_id IS NOT OLD.owner_id OR NEW.token_hash IS NOT OLD.token_hash OR NEW.kind IS NOT OLD.kind OR NEW.issued_at IS NOT OLD.issued_at OR NEW.expires_at IS NOT OLD.expires_at OR (OLD.revoked_at IS NOT NULL AND NEW.revoked_at IS NOT OLD.revoked_at) BEGIN SELECT RAISE(ABORT,'authentication invariant'); END" + ) + + execute( + "CREATE TRIGGER owner_authenticator_retained BEFORE DELETE ON owner_authenticators BEGIN SELECT RAISE(ABORT,'authentication invariant'); END" + ) + + execute( + "CREATE TRIGGER owner_credentials_retained BEFORE DELETE ON owner_credentials BEGIN SELECT RAISE(ABORT,'authentication invariant'); END" + ) + end + + def down do + drop table(:owner_credentials) + drop table(:owner_authenticators) + end +end diff --git a/apps/control_plane/priv/repo/migrations/20261014020000_host_identity.exs b/apps/control_plane/priv/repo/migrations/20261014020000_host_identity.exs new file mode 100644 index 00000000..03ff5287 --- /dev/null +++ b/apps/control_plane/priv/repo/migrations/20261014020000_host_identity.exs @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Repo.Migrations.HostIdentity do + use Ecto.Migration + + def up do + execute( + "CREATE TABLE host_security (host_id TEXT PRIMARY KEY NOT NULL, owner_id TEXT NOT NULL, revoked_at INTEGER, FOREIGN KEY(host_id,owner_id) REFERENCES hosts(id,owner_id))" + ) + + execute( + "CREATE TABLE host_enrollments (id TEXT PRIMARY KEY NOT NULL, host_id TEXT NOT NULL, owner_id TEXT NOT NULL, code_hash BLOB UNIQUE NOT NULL, issued_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, consumed_at INTEGER, revoked_at INTEGER, FOREIGN KEY(host_id,owner_id) REFERENCES hosts(id,owner_id), CHECK(expires_at=issued_at+300))" + ) + + execute( + "CREATE TABLE host_identities (id TEXT PRIMARY KEY NOT NULL, host_id TEXT NOT NULL, owner_id TEXT NOT NULL, fingerprint TEXT UNIQUE NOT NULL, certificate BLOB NOT NULL, csr_hash BLOB NOT NULL, predecessor_id TEXT, status TEXT NOT NULL CHECK(status IN ('active','pending','retired','revoked')), issued_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, activated_at INTEGER, UNIQUE(id,host_id), FOREIGN KEY(host_id,owner_id) REFERENCES hosts(id,owner_id), FOREIGN KEY(predecessor_id,host_id) REFERENCES host_identities(id,host_id), CHECK(expires_at=issued_at+86400))" + ) + + execute( + "CREATE UNIQUE INDEX one_active_host_identity ON host_identities(host_id) WHERE status='active'" + ) + + execute( + "CREATE UNIQUE INDEX one_pending_host_identity ON host_identities(host_id) WHERE status='pending'" + ) + + execute( + "CREATE TABLE host_connection_slots (host_id TEXT PRIMARY KEY NOT NULL, owner_id TEXT NOT NULL, connection_id TEXT, state TEXT NOT NULL CHECK(state IN ('unknown','connected')), FOREIGN KEY(host_id,owner_id) REFERENCES hosts(id,owner_id))" + ) + + execute( + "CREATE TABLE runtime_freshness (runtime_id TEXT PRIMARY KEY NOT NULL, generation INTEGER NOT NULL CHECK(typeof(generation)='integer' AND generation>0), intent_version INTEGER NOT NULL CHECK(typeof(intent_version)='integer' AND intent_version>0), connection_id TEXT NOT NULL, FOREIGN KEY(runtime_id,generation) REFERENCES runtime_instances(id,generation))" + ) + + execute( + "CREATE TRIGGER host_revocation_retained BEFORE UPDATE ON host_security WHEN NEW.host_id IS NOT OLD.host_id OR NEW.owner_id IS NOT OLD.owner_id OR (OLD.revoked_at IS NOT NULL AND NEW.revoked_at IS NOT OLD.revoked_at) BEGIN SELECT RAISE(ABORT,'host invariant'); END" + ) + + execute( + "CREATE TRIGGER host_identity_revoked_insert BEFORE INSERT ON host_identities WHEN NEW.status IN ('active','pending') AND EXISTS(SELECT 1 FROM host_security WHERE host_id=NEW.host_id AND revoked_at IS NOT NULL) BEGIN SELECT RAISE(ABORT,'host invariant'); END" + ) + + execute( + "CREATE TRIGGER host_identity_retained BEFORE UPDATE ON host_identities WHEN NEW.host_id IS NOT OLD.host_id OR NEW.owner_id IS NOT OLD.owner_id OR NEW.fingerprint IS NOT OLD.fingerprint OR NEW.certificate IS NOT OLD.certificate OR NEW.csr_hash IS NOT OLD.csr_hash OR NEW.predecessor_id IS NOT OLD.predecessor_id OR NEW.issued_at IS NOT OLD.issued_at OR NEW.expires_at IS NOT OLD.expires_at OR (OLD.status='revoked' AND NEW.status!='revoked') OR (OLD.status='retired' AND NEW.status NOT IN ('retired','revoked')) OR (NEW.status IN ('active','pending') AND EXISTS(SELECT 1 FROM host_security WHERE host_id=NEW.host_id AND revoked_at IS NOT NULL)) BEGIN SELECT RAISE(ABORT,'host invariant'); END" + ) + + for table <- ~w(host_security host_enrollments host_identities) do + execute( + "CREATE TRIGGER #{table}_retained BEFORE DELETE ON #{table} BEGIN SELECT RAISE(ABORT,'host invariant'); END" + ) + end + end + + def down, do: raise("identity/revocation history must not be discarded") +end diff --git a/apps/control_plane/priv/repo/migrations/20261015000000_revision_trust.exs b/apps/control_plane/priv/repo/migrations/20261015000000_revision_trust.exs new file mode 100644 index 00000000..73ea2ad4 --- /dev/null +++ b/apps/control_plane/priv/repo/migrations/20261015000000_revision_trust.exs @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.Repo.Migrations.RevisionTrust do + use Ecto.Migration + + def up do + execute(""" + CREATE TABLE revision_trust_approvals ( + id TEXT PRIMARY KEY NOT NULL, owner_id TEXT NOT NULL REFERENCES owners(id), + descriptor TEXT NOT NULL, descriptor_bytes BLOB NOT NULL, descriptor_sha256 TEXT NOT NULL, + approved_at INTEGER NOT NULL CHECK(typeof(approved_at)='integer' AND approved_at>=0), + inserted_at TEXT NOT NULL, updated_at TEXT NOT NULL, + UNIQUE(owner_id,descriptor_sha256), UNIQUE(owner_id,descriptor_bytes)) + """) + + execute( + "CREATE TRIGGER revision_approval_immutable BEFORE UPDATE ON revision_trust_approvals WHEN 1 BEGIN SELECT RAISE(ABORT, 'immutable revision approval'); END" + ) + + execute( + "CREATE TRIGGER revision_approval_retained BEFORE DELETE ON revision_trust_approvals WHEN 1 BEGIN SELECT RAISE(ABORT, 'retained revision approval'); END" + ) + end + + def down do + execute("DROP TRIGGER revision_approval_retained") + execute("DROP TRIGGER revision_approval_immutable") + execute("DROP TABLE revision_trust_approvals") + end +end diff --git a/apps/control_plane/priv/repo/seeds.exs b/apps/control_plane/priv/repo/seeds.exs new file mode 100644 index 00000000..6f8753a9 --- /dev/null +++ b/apps/control_plane/priv/repo/seeds.exs @@ -0,0 +1,11 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# WorkHarbor.Repo.insert!(%WorkHarbor.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. diff --git a/apps/control_plane/priv/static/favicon.ico b/apps/control_plane/priv/static/favicon.ico new file mode 100644 index 00000000..7f372bfc Binary files /dev/null and b/apps/control_plane/priv/static/favicon.ico differ diff --git a/apps/control_plane/priv/static/images/logo.svg b/apps/control_plane/priv/static/images/logo.svg new file mode 100644 index 00000000..9f26baba --- /dev/null +++ b/apps/control_plane/priv/static/images/logo.svg @@ -0,0 +1,6 @@ + + + diff --git a/apps/control_plane/priv/static/robots.txt b/apps/control_plane/priv/static/robots.txt new file mode 100644 index 00000000..26e06b5f --- /dev/null +++ b/apps/control_plane/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/apps/control_plane/test/support/acceptance_formatter.ex b/apps/control_plane/test/support/acceptance_formatter.ex new file mode 100644 index 00000000..2248b689 --- /dev/null +++ b/apps/control_plane/test/support/acceptance_formatter.ex @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.AcceptanceFormatter do + @moduledoc false + use GenServer + + def init(_opts) do + path = System.fetch_env!("WORKHARBOR_EXUNIT_EVENTS") + {:ok, File.open!(path, [:write, :exclusive, :utf8])} + end + + def handle_cast({:suite_started, _opts}, io) do + emit(io, %{event: "suite_started"}) + {:noreply, io} + end + + def handle_cast({:test_finished, test}, io) do + status = if is_nil(test.state), do: "passed", else: "not_passed" + + emit(io, %{event: "test_finished", id: "#{inspect(test.module)}/#{test.name}", status: status}) + + {:noreply, io} + end + + def handle_cast({:suite_finished, _times}, io) do + emit(io, %{event: "suite_finished"}) + {:noreply, io} + end + + def handle_cast(_event, io), do: {:noreply, io} + + defp emit(io, event), do: IO.write(io, Jason.encode!(event) <> "\n") +end diff --git a/apps/control_plane/test/support/auth_database.ex b/apps/control_plane/test/support/auth_database.ex new file mode 100644 index 00000000..0393646a --- /dev/null +++ b/apps/control_plane/test/support/auth_database.ex @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.AuthDatabase do + def open!(path) do + pid = WorkHarbor.FoundationDatabase.open!(path) + + unless Code.ensure_loaded?(WorkHarbor.Repo.Migrations.OwnerAuthentication) do + Code.require_file( + Application.app_dir( + :workharbor, + "priv/repo/migrations/20261014010000_owner_authentication.exs" + ) + ) + end + + Ecto.Migrator.run( + WorkHarbor.Repo, + [{20_261_014_010_000, WorkHarbor.Repo.Migrations.OwnerAuthentication}], + :up, + all: true, + log: false + ) + + pid + end + + def fixture! do + {:ok, owner} = + WorkHarbor.Accounts.bootstrap(fn secret -> + send(self(), {:bootstrap_secret, secret}) + :ok + end) + + receive do + {:bootstrap_secret, secret} -> {owner.id, secret} + end + end +end diff --git a/apps/control_plane/test/support/config_trust_database.ex b/apps/control_plane/test/support/config_trust_database.ex new file mode 100644 index 00000000..e9459132 --- /dev/null +++ b/apps/control_plane/test/support/config_trust_database.ex @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.ConfigTrustDatabase do + def open!(path) do + pid = WorkHarbor.HostDatabase.open!(path) + + unless Code.ensure_loaded?(WorkHarbor.Repo.Migrations.RevisionTrust) do + Code.require_file( + Application.app_dir(:workharbor, "priv/repo/migrations/20261015000000_revision_trust.exs") + ) + end + + Ecto.Migrator.run( + WorkHarbor.Repo, + [{20_261_015_000_000, WorkHarbor.Repo.Migrations.RevisionTrust}], + :up, + all: true, + log: false + ) + + pid + end +end diff --git a/apps/control_plane/test/support/conn_case.ex b/apps/control_plane/test/support/conn_case.ex new file mode 100644 index 00000000..35ca5dd9 --- /dev/null +++ b/apps/control_plane/test/support/conn_case.ex @@ -0,0 +1,38 @@ +defmodule WorkHarborWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use WorkHarborWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint WorkHarborWeb.Endpoint + + use WorkHarborWeb, :verified_routes + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import WorkHarborWeb.ConnCase + end + end + + setup tags do + WorkHarbor.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/apps/control_plane/test/support/data_case.ex b/apps/control_plane/test/support/data_case.ex new file mode 100644 index 00000000..d0abce6b --- /dev/null +++ b/apps/control_plane/test/support/data_case.ex @@ -0,0 +1,58 @@ +defmodule WorkHarbor.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use WorkHarbor.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + alias WorkHarbor.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import WorkHarbor.DataCase + end + end + + setup tags do + WorkHarbor.DataCase.setup_sandbox(tags) + :ok + end + + @doc """ + Sets up the sandbox based on the test tags. + """ + def setup_sandbox(tags) do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(WorkHarbor.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/apps/control_plane/test/support/foundation_database.ex b/apps/control_plane/test/support/foundation_database.ex new file mode 100644 index 00000000..da04160f --- /dev/null +++ b/apps/control_plane/test/support/foundation_database.ex @@ -0,0 +1,129 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.FoundationDatabase do + @moduledoc false + alias WorkHarbor.Repo + + def open!(path) do + {:ok, pid} = + Repo.start_link( + name: nil, + database: path, + pool: DBConnection.ConnectionPool, + pool_size: 1, + journal_mode: :wal, + foreign_keys: :on, + synchronous: :full, + busy_timeout: 2000, + log: false + ) + + Process.unlink(pid) + Repo.put_dynamic_repo(pid) + migration = WorkHarbor.Repo.Migrations.OwnerFoundation + + unless Code.ensure_loaded?(migration), + do: + Code.require_file( + Application.app_dir( + :workharbor, + "priv/repo/migrations/20261014000000_owner_foundation.exs" + ) + ) + + Ecto.Migrator.run(Repo, [{20_261_014_000_000, migration}], :up, all: true, log: false) + GenServer.stop(pid) + + {:ok, writers} = + Repo.start_link( + name: nil, + database: path, + pool: DBConnection.ConnectionPool, + pool_size: 5, + journal_mode: :wal, + foreign_keys: :on, + synchronous: :full, + busy_timeout: 2000, + log: false + ) + + Process.unlink(writers) + Repo.put_dynamic_repo(writers) + writers + end + + def sql(statement, parameters \\ []) do + Repo.query!(statement, parameters, log: false) + end + + def count(table), do: sql("SELECT count(*) FROM " <> table).rows |> hd() |> hd() + + def fixture! do + alias WorkHarbor.Foundation, as: F + {:ok, owner} = F.create_owner("private owner name") + {:ok, host} = F.create_host(owner.id) + {owner.id, host.id} + end + + def request(host, workspace \\ Ecto.UUID.generate(), action \\ "create") do + parameters = if action == "create", do: %{"host_id" => host, "config" => config()}, else: %{} + + %{ + "schema_version" => 1, + "workspace_id" => workspace, + "action" => action, + "parameters" => parameters + } + end + + def config(commit \\ String.duplicate("a", 40)) do + %{ + "repository_identity" => "git@example.test:private-repository.git", + "commit" => commit, + "submodules" => [] + } + end + + # A historical confirmed runtime fixture; no provider or lifecycle is invoked. + def current!(operation) do + alias WorkHarbor.Foundation, as: F + + candidate = + F.bindings( + operation.owner_id, + sql("SELECT host_id FROM workspaces WHERE id = ?", [operation.workspace_id]).rows + |> hd() + |> hd(), + operation.id + )["candidate"] + + sql( + "UPDATE runtime_instances SET is_current = 1, observed_state = 'running', effective_config = ? WHERE id = ?", + [Jason.encode!(config()), candidate["id"]] + ) + + candidate + end + + def finish!(owner, host, operation, status \\ "failed") do + alias WorkHarbor.Foundation, as: F + + outcome = + if status == "failed", + do: %{"status" => status, "code" => "operation_failed"}, + else: %{"status" => status, "code" => "ok"} + + {:ok, {:finished, result}} = + F.finish( + owner, + host, + operation.id, + operation.intent_version, + operation.status, + F.bindings(owner, host, operation.id), + outcome + ) + + result + end +end diff --git a/apps/control_plane/test/support/host_database.ex b/apps/control_plane/test/support/host_database.ex new file mode 100644 index 00000000..e77d726b --- /dev/null +++ b/apps/control_plane/test/support/host_database.ex @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostDatabase do + def open!(path) do + pid = WorkHarbor.AuthDatabase.open!(path) + + unless Code.ensure_loaded?(WorkHarbor.Repo.Migrations.HostIdentity) do + Code.require_file( + Application.app_dir(:workharbor, "priv/repo/migrations/20261014020000_host_identity.exs") + ) + end + + Ecto.Migrator.run( + WorkHarbor.Repo, + [{20_261_014_020_000, WorkHarbor.Repo.Migrations.HostIdentity}], + :up, + all: true, + log: false + ) + + pid + end +end diff --git a/apps/control_plane/test/support/host_file_fault.ex b/apps/control_plane/test/support/host_file_fault.ex new file mode 100644 index 00000000..e8c0c144 --- /dev/null +++ b/apps/control_plane/test/support/host_file_fault.ex @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostFileFault do + def open(path, flags) do + result = + fail(if(:directory in flags, do: :parent_open, else: :file_open), fn -> + :file.open(path, flags) + end) + + case result do + {:ok, fd} -> + Process.put({__MODULE__, fd}, if(:directory in flags, do: :parent, else: :file)) + + _ -> + :ok + end + + result + end + + def write(fd, bytes) do + if Process.get({__MODULE__, :stage}) == :write do + :ok = :file.write(fd, binary_part(bytes, 0, div(byte_size(bytes), 2))) + {:error, :eio} + else + :file.write(fd, bytes) + end + end + + def sync(fd), do: fail({Process.get({__MODULE__, fd}), :sync}, fn -> :file.sync(fd) end) + + def close(fd) do + kind = Process.delete({__MODULE__, fd}) + result = :file.close(fd) + fail({kind, :close}, fn -> result end) + end + + def make_link(a, b), do: fail(:link, fn -> :file.make_link(a, b) end) + + defp fail(stage, run) do + if Process.get({__MODULE__, :stage}) == stage, do: {:error, :eio}, else: run.() + end +end diff --git a/apps/control_plane/test/support/host_fixture.ex b/apps/control_plane/test/support/host_fixture.ex new file mode 100644 index 00000000..d2665cf6 --- /dev/null +++ b/apps/control_plane/test/support/host_fixture.ex @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostFixture do + @moduledoc false + def csr(host \\ nil) do + {public, private} = :crypto.generate_key(:eddsa, :ed25519) + {csr_bytes(public, private, host), public} + end + + def csr_with_key do + key = :public_key.generate_key({:namedCurve, {1, 3, 101, 112}}) + {csr_bytes(elem(key, 4), elem(key, 2), nil), key} + end + + defp csr_bytes(public, private, host) do + subject = + if host, + do: {:rdnSequence, [[{:AttributeTypeAndValue, {2, 5, 4, 3}, {:utf8String, host}}]]}, + else: {:rdnSequence, []} + + info = + {:CertificationRequestInfo, 0, subject, + {:CertificationRequestInfo_subjectPKInfo, + {:CertificationRequestInfo_subjectPKInfo_algorithm, {1, 3, 101, 112}, :asn1_NOVALUE}, + public}, []} + + bytes = :public_key.der_encode(:CertificationRequestInfo, info) + signature = :crypto.sign(:eddsa, :none, bytes, [private, :ed25519]) + + :public_key.der_encode( + :CertificationRequest, + {:CertificationRequest, info, + {:CertificationRequest_signatureAlgorithm, {1, 3, 101, 112}, :asn1_NOVALUE}, signature} + ) + end + + # Resolve trusted OS temp aliases only in fixtures; production private paths + # refuse every symlink component and never silently resolve them. + def physical_temp do + ["/" | parts] = Path.split(System.tmp_dir!()) + physical_parts("/", parts, 0) + end + + defp physical_parts(path, [], _), do: path + + defp physical_parts(path, [part | rest], depth) when depth < 40 do + next = Path.join(path, part) + + case File.read_link(next) do + {:ok, target} -> + ["/" | resolved] = Path.split(Path.expand(target, path)) + physical_parts("/", resolved ++ rest, depth + 1) + + {:error, :einval} -> + physical_parts(next, rest, depth) + + _ -> + raise("unavailable physical fixture directory") + end + end +end diff --git a/apps/control_plane/test/support/host_log_capture.ex b/apps/control_plane/test/support/host_log_capture.ex new file mode 100644 index 00000000..f8a50957 --- /dev/null +++ b/apps/control_plane/test/support/host_log_capture.ex @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostLogCapture do + @moduledoc "Test handler receives events only after all native primary filters." + def adding_handler(config), do: {:ok, config} + def removing_handler(_), do: :ok + def changing_config(_, _, config), do: {:ok, config} + def log(event, %{config: %{test: test}}), do: send(test, {:after_primary_log, event}) +end diff --git a/apps/control_plane/test/support/host_transport_endpoint.ex b/apps/control_plane/test/support/host_transport_endpoint.ex new file mode 100644 index 00000000..cf76f122 --- /dev/null +++ b/apps/control_plane/test/support/host_transport_endpoint.ex @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostTransportEndpoint do + @moduledoc "Test-only committed fixture repository wiring; production ingress is unchanged." + def init(opts), do: opts + + def call(conn, _) do + WorkHarbor.Repo.put_dynamic_repo( + Application.fetch_env!(:workharbor, :host_transport_fixture_repo) + ) + + WorkHarborWeb.Endpoint.call(conn, WorkHarborWeb.Endpoint.init([])) + end +end diff --git a/apps/control_plane/test/test_helper.exs b/apps/control_plane/test/test_helper.exs new file mode 100644 index 00000000..7189e644 --- /dev/null +++ b/apps/control_plane/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(WorkHarbor.Repo, :manual) diff --git a/apps/control_plane/test/workharbor/accounts_test.exs b/apps/control_plane/test/workharbor/accounts_test.exs new file mode 100644 index 00000000..7cdefc6a --- /dev/null +++ b/apps/control_plane/test/workharbor/accounts_test.exs @@ -0,0 +1,215 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.AccountsTest do + use ExUnit.Case, async: false + alias WorkHarbor.{Accounts, Repo} + alias WorkHarbor.FoundationDatabase, as: DB + + setup do + path = Path.join(System.tmp_dir!(), "workharbor-auth-#{Ecto.UUID.generate()}.db") + pid = WorkHarbor.AuthDatabase.open!(path) + Application.put_env(:workharbor, :clock, fn -> 2_000_000_000 end) + + on_exit(fn -> + Application.delete_env(:workharbor, :clock) + if Process.alive?(pid), do: GenServer.stop(pid) + for s <- ["", "-wal", "-shm"], do: File.rm(path <> s) + end) + + %{path: path, pid: pid} + end + + test "bootstrap delivery failure never reinitializes owner" do + assert {:error, :delivery_failed} = Accounts.bootstrap(fn _ -> raise "SECRET-CANARY" end) + assert DB.count("owner_authenticators") == 1 + assert DB.count("owners") == 1 + assert {:error, :already_initialized} = Accounts.bootstrap(fn _ -> flunk("redelivery") end) + end + + test "bootstrap writers serialize a single verifier" do + repo = Repo.get_dynamic_repo() + parent = self() + + results = + for _ <- 1..4 do + Task.async(fn -> + Repo.put_dynamic_repo(repo) + + Accounts.bootstrap(fn s -> + send(parent, {:delivered, s}) + :ok + end) + end) + end + |> Enum.map(&Task.await(&1, 5000)) + + assert Enum.count(results, &match?({:ok, _}, &1)) == 1 + assert Enum.count(results, &(&1 == {:error, :already_initialized})) == 3 + assert_receive {:delivered, s} + assert byte_size(s) == 43 + refute_receive {:delivered, _} + end + + test "primary auth fixed scopes and hashes preserve secret safety" do + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + assert {:error, :unauthenticated} = Accounts.login(Ecto.UUID.generate(), secret, "cli_owner") + + assert {:error, :unauthenticated} = + Accounts.login(owner, String.duplicate("x", 43), "cli_owner") + + for {kind, ttl} <- [ + {"cli_owner", 2_592_000}, + {"owner_browser", 28_800}, + {"dashboard", 28_800} + ] do + assert {:ok, token, p} = Accounts.login(owner, secret, kind) + assert byte_size(token) == 48 + assert p.expires_at - p.issued_at == ttl + assert {:ok, _} = Accounts.authenticate(token, kind) + assert {:error, :unauthenticated} = Accounts.authenticate(token, "unknown") + refute inspect(Repo.all(Accounts.Credential)) =~ token + end + + refute inspect(DB.sql("SELECT * FROM owner_authenticators").rows) =~ secret + assert {:error, :invalid_request} = Accounts.login(owner, secret, "invented") + end + + test "exact expiry revoked reopen and independent credentials", c do + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, token, p} = Accounts.login(owner, secret, "cli_owner") + {:ok, other, _} = Accounts.login(owner, secret, "cli_owner") + Application.put_env(:workharbor, :clock, fn -> p.expires_at - 1 end) + assert {:ok, _} = Accounts.authenticate(token) + Application.put_env(:workharbor, :clock, fn -> p.expires_at end) + assert {:error, :unauthenticated} = Accounts.authenticate(token) + Application.put_env(:workharbor, :clock, fn -> p.issued_at end) + assert :ok = Accounts.revoke(p, p.credential_id) + GenServer.stop(c.pid) + pid = WorkHarbor.AuthDatabase.open!(c.path) + assert {:error, :unauthenticated} = Accounts.authenticate(token) + assert {:ok, _} = Accounts.authenticate(other) + GenServer.stop(pid) + end + + test "read scope denies mutations grants unknown action and foreign ownership" do + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, _, p} = Accounts.login(owner, secret, "dashboard") + + for a <- [:logout, :revoke, :issue_dashboard, :primary_login, :unknown], + do: assert({:error, :forbidden} == Accounts.authorize(p, a, owner)) + + assert {:error, :forbidden} = Accounts.revoke(p, p.credential_id) + assert {:error, :forbidden} = Accounts.issue_dashboard(p) + assert DB.count("owner_credentials") == 1 + assert DB.sql("SELECT revoked_at FROM owner_credentials").rows == [[nil]] + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event='authorization_denied'").rows == + [[7]] + + {:ok, _, write} = Accounts.login(owner, secret, "cli_owner") + assert {:error, :forbidden} = Accounts.authorize(write, :revoke, Ecto.UUID.generate()) + end + + test "read issuance and fresh login do not automatically revoke previous tokens" do + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, first, p} = Accounts.login(owner, secret, "owner_browser") + assert {:ok, read, r} = Accounts.issue_dashboard(p) + assert r.scopes == ["dashboard:read"] + assert {:ok, _, _} = Accounts.login(owner, secret, "owner_browser") + assert {:ok, _} = Accounts.authenticate(first) + assert {:ok, _} = Accounts.authenticate(read) + assert :ok = Accounts.revoke(p, r.credential_id) + assert {:error, :unauthenticated} = Accounts.authenticate(read) + end + + test "revocation audit distinguishes actor target and unrelated viewer" do + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, _, actor} = Accounts.login(owner, secret, "cli_owner") + {:ok, first, target} = Accounts.issue_dashboard(actor) + {:ok, second, _} = Accounts.issue_dashboard(actor) + assert :ok = Accounts.revoke(actor, target.credential_id) + + [[metadata]] = + DB.sql("SELECT metadata FROM audit_events WHERE event='credential_revoked'").rows + + assert Jason.decode!(metadata) == %{ + "actor_credential_id" => actor.credential_id, + "target_credential_id" => target.credential_id + } + + assert {:error, :unauthenticated} = Accounts.authenticate(first) + assert {:ok, _} = Accounts.authenticate(second) + end + + test "direct SQL cannot relabel credentials extend expiry or delete authenticator" do + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, _, p} = Accounts.login(owner, secret, "dashboard") + + for sql <- [ + "UPDATE owner_credentials SET kind='cli_owner'", + "UPDATE owner_credentials SET expires_at=expires_at+1", + "UPDATE owner_authenticators SET owner_id='invented'", + "DELETE FROM owner_authenticators" + ] do + assert_raise Exqlite.Error, fn -> DB.sql(sql) end + end + + assert Repo.get(Accounts.Credential, p.credential_id).kind == "dashboard" + end + + test "foreign or malformed revoke denial is audited without raw target content" do + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, _, p} = Accounts.login(owner, secret, "cli_owner") + assert {:error, :not_found} = Accounts.revoke(p, "SECRET-CANARY") + + [[action, metadata]] = + DB.sql("SELECT action,metadata FROM audit_events WHERE event='authorization_denied'").rows + + assert action == "revoke" + refute metadata =~ "SECRET-CANARY" + assert Jason.decode!(metadata)["credential_id"] == p.credential_id + end + + test "bootstrap preserves owner metadata audit and rejects nested transactions" do + {owner, _} = WorkHarbor.AuthDatabase.fixture!() + + assert DB.sql("SELECT event FROM audit_events WHERE event='owner_created' AND owner_id=?", [ + owner + ]).rows == [["owner_created"]] + + assert {:ok, :ok} = + Repo.transaction(fn -> + assert {:error, :nested_transaction} = + Accounts.bootstrap(fn _ -> flunk("nested delivery") end) + + :ok + end) + end + + test "existing foreign credential revoke is concealed audited and unchanged" do + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, _, actor} = Accounts.login(owner, secret, "cli_owner") + {:ok, foreign} = WorkHarbor.Foundation.create_owner("foreign metadata fixture") + + target = + Repo.insert!(%Accounts.Credential{ + owner_id: foreign.id, + kind: "dashboard", + token_hash: :crypto.strong_rand_bytes(32), + issued_at: Accounts.now(), + expires_at: Accounts.now() + 100 + }) + + assert Repo.get(Accounts.Credential, target.id) == target + assert {:error, :not_found} = Accounts.revoke(actor, target.id) + assert Repo.get(Accounts.Credential, target.id) == target + + [[audit_owner, action, code, metadata]] = + DB.sql( + "SELECT owner_id,action,code,metadata FROM audit_events WHERE event='authorization_denied'" + ).rows + + assert audit_owner == owner and action == "revoke" and code == "not_found" + assert Jason.decode!(metadata) == %{"credential_id" => actor.credential_id} + end +end diff --git a/apps/control_plane/test/workharbor/config_trust_test.exs b/apps/control_plane/test/workharbor/config_trust_test.exs new file mode 100644 index 00000000..4cd3ecb0 --- /dev/null +++ b/apps/control_plane/test/workharbor/config_trust_test.exs @@ -0,0 +1,189 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.ConfigTrustTest do + use ExUnit.Case, async: false + alias WorkHarbor.{Accounts, ConfigTrust, Repo} + alias WorkHarbor.FoundationDatabase, as: DB + + setup do + path = Path.join(System.tmp_dir!(), "workharbor-trust-#{Ecto.UUID.generate()}.db") + pid = WorkHarbor.ConfigTrustDatabase.open!(path) + Application.put_env(:workharbor, :clock, fn -> 2_000_000_000 end) + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, _token, principal} = Accounts.login(owner, secret, "cli_owner") + + on_exit(fn -> + Application.delete_env(:workharbor, :clock) + if Process.alive?(pid), do: GenServer.stop(pid) + for suffix <- ["", "-wal", "-shm"], do: File.rm(path <> suffix) + end) + + %{owner: owner, secret: secret, principal: principal} + end + + test "exact approval repeats without a second approval or audit", ctx do + descriptor = DB.config() + assert {:ok, first} = ConfigTrust.approve(ctx.principal, descriptor) + assert first.owner_id == ctx.owner + assert first.descriptor == descriptor + assert first.approved_at == 2_000_000_000 + assert {:ok, second} = ConfigTrust.approve(ctx.principal, descriptor) + assert first.id == second.id + assert {:ok, ^first} = ConfigTrust.lookup(ctx.principal, first.id, descriptor) + assert DB.count("revision_trust_approvals") == 1 + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event='revision_approved'").rows == [ + [1] + ] + end + + test "changed config submodule origin and complete set require new approval", ctx do + d = + Map.put(DB.config(), "submodules", [ + %{"path" => "projects/app", "commit" => String.duplicate("b", 40)} + ]) + + assert {:ok, approval} = ConfigTrust.approve(ctx.principal, d) + + for changed <- [ + Map.put(d, "commit", String.duplicate("c", 40)), + Map.put(d, "repository_identity", "git@EXAMPLE.test:private-repository.git"), + Map.put(d, "submodules", []), + put_in(d, ["submodules", Access.at(0), "commit"], String.duplicate("d", 40)) + ] do + assert {:error, :trust_required} = ConfigTrust.lookup(ctx.principal, approval.id, changed) + assert {:ok, next} = ConfigTrust.approve(ctx.principal, changed) + refute next.id == approval.id + end + end + + test "unknown null malformed descriptors never approve", ctx do + for bad <- [ + nil, + %{}, + Map.put(DB.config(), "owner_id", ctx.owner), + Map.put(DB.config(), "commit", "main"), + Map.put(DB.config(), "submodules", nil) + ] do + assert {:error, :invalid_configuration} = ConfigTrust.approve(ctx.principal, bad) + end + + assert DB.count("revision_trust_approvals") == 0 + end + + test "dashboard revoked and forged principals cannot approve", ctx do + {:ok, _token, dashboard} = Accounts.login(ctx.owner, ctx.secret, "dashboard") + assert {:error, :forbidden} = ConfigTrust.approve(dashboard, DB.config()) + forged = %{ctx.principal | owner_id: Ecto.UUID.generate()} + assert {:error, :forbidden} = ConfigTrust.approve(forged, DB.config()) + assert :ok = Accounts.revoke(ctx.principal, ctx.principal.credential_id) + assert {:error, :forbidden} = ConfigTrust.approve(ctx.principal, DB.config()) + end + + test "dashboard may read exact approval but cannot use a foreign owner", ctx do + assert {:ok, approval} = ConfigTrust.approve(ctx.principal, DB.config()) + {:ok, _token, dashboard} = Accounts.login(ctx.owner, ctx.secret, "dashboard") + assert {:ok, _} = ConfigTrust.lookup(dashboard, approval.id, DB.config()) + + assert {:error, :forbidden} = + ConfigTrust.lookup( + %{dashboard | owner_id: Ecto.UUID.generate()}, + approval.id, + DB.config() + ) + end + + test "expired credentials cannot approve or read", ctx do + Application.put_env(:workharbor, :clock, fn -> ctx.principal.expires_at end) + assert {:error, :forbidden} = ConfigTrust.approve(ctx.principal, DB.config()) + + assert {:error, :forbidden} = + ConfigTrust.lookup(ctx.principal, Ecto.UUID.generate(), DB.config()) + end + + test "approval identity is immutable and owner FK is enforced", ctx do + assert {:ok, approval} = ConfigTrust.approve(ctx.principal, DB.config()) + + assert_raise Exqlite.Error, fn -> + DB.sql("UPDATE revision_trust_approvals SET approved_at=0 WHERE id=?", [approval.id]) + end + + assert_raise Exqlite.Error, fn -> + DB.sql("DELETE FROM revision_trust_approvals WHERE id=?", [approval.id]) + end + + assert_raise Exqlite.Error, fn -> + DB.sql( + """ + INSERT INTO revision_trust_approvals + (id,owner_id,descriptor,descriptor_bytes,descriptor_sha256,approved_at,inserted_at,updated_at) + VALUES (?,?,'{}',X'00','foreign',0,'now','now') + """, + [Ecto.UUID.generate(), Ecto.UUID.generate()] + ) + end + + assert {:ok, ^approval} = ConfigTrust.lookup(ctx.principal, approval.id, DB.config()) + refute inspect(approval) =~ "private-repository" + end + + test "host approval lookup rechecks genuine current assignment and revocation", ctx do + dir = + Path.join( + WorkHarbor.HostFixture.physical_temp(), + "whr-trust-issuer-#{Ecto.UUID.generate()}" + ) + + previous = Application.get_env(:workharbor, :host_identity) + + on_exit(fn -> + if previous, + do: Application.put_env(:workharbor, :host_identity, previous), + else: Application.delete_env(:workharbor, :host_identity) + + File.rm_rf!(dir) + end) + + assert {:ok, _} = WorkHarbor.Hosts.Issuer.initialize(dir) + Application.put_env(:workharbor, :host_identity, issuer_dir: dir) + assert {:ok, approval} = ConfigTrust.approve(ctx.principal, DB.config()) + assert {:ok, issued} = WorkHarbor.Hosts.issue_enrollment(ctx.principal, nil) + {csr, _} = WorkHarbor.HostFixture.csr() + assert {:ok, enrolled} = WorkHarbor.Hosts.redeem(issued.code, csr) + + assert {:ok, peer} = + WorkHarbor.Hosts.resolve_identity(Base.decode64!(enrolled["certificate"]), :active) + + assert {:ok, ^approval} = ConfigTrust.lookup_for_host(peer, approval.id, DB.config()) + + assert {:error, :trust_required} = + ConfigTrust.lookup_for_host(peer, approval.id, DB.config(String.duplicate("c", 40))) + + assert {:error, :forbidden} = + ConfigTrust.lookup_for_host( + %{peer | owner_id: Ecto.UUID.generate()}, + approval.id, + DB.config() + ) + + assert :ok = WorkHarbor.Hosts.revoke(ctx.principal, peer.host_id) + assert {:error, :forbidden} = ConfigTrust.lookup_for_host(peer, approval.id, DB.config()) + end + + test "concurrent independent writers record one immutable revision approval", ctx do + repo = Repo.get_dynamic_repo() + + results = + for _ <- 1..4 do + Task.async(fn -> + Repo.put_dynamic_repo(repo) + ConfigTrust.approve(ctx.principal, DB.config()) + end) + end + |> Enum.map(&Task.await(&1, 5000)) + + assert Enum.all?(results, &match?({:ok, _}, &1)) + assert results |> Enum.map(fn {:ok, row} -> row.id end) |> Enum.uniq() |> length() == 1 + assert DB.count("revision_trust_approvals") == 1 + end +end diff --git a/apps/control_plane/test/workharbor/foundation_request_test.exs b/apps/control_plane/test/workharbor/foundation_request_test.exs new file mode 100644 index 00000000..b7513f12 --- /dev/null +++ b/apps/control_plane/test/workharbor/foundation_request_test.exs @@ -0,0 +1,246 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.FoundationRequestTest do + use ExUnit.Case, async: true + alias WorkHarbor.Foundation.{Canonical, Request, RepositoryIdentity} + alias WorkHarbor.FoundationDatabase, as: DB + @owner "00000000-0000-4000-8000-000000000001" + @host "00000000-0000-4000-8000-000000000002" + @workspace "00000000-0000-4000-8000-000000000003" + + test "native canonical bytes match independent frozen anchors" do + anchors = [ + {0, "30", "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"}, + {%{"\uE000" => 1, "\u{10000}" => 2}, "7b22ee8080223a312c22f0908080223a327d", + "8706b5798a29d65739b3d1bbb1d009f87d005c71c8130c5353c4990c15ddfec8"}, + {"\u2028\u2029", "22e280a8e280a922", + "e3a0e2262ac7790f4df36b522a8fbdd4ee3370d568eb15dc25c5f1775f14ed6c"}, + {"<>&/", "223c3e262f22", + "396adbc953af00557241e2c859a735e8a11b03279d475359cf45dffed8746492"}, + {%{"b" => true, "i" => 1}, "7b2262223a747275652c2269223a317d", + "1dc08b312fcdfc60ec48be27868b868ea4846bcb42e831b21f5db31162f31c63"} + ] + + for {native, hex, digest} <- anchors do + assert {:ok, bytes} = Canonical.bytes(native) + assert Base.encode16(bytes, case: :lower) == hex + assert Base.encode16(:crypto.hash(:sha256, bytes), case: :lower) == digest + end + + assert {:ok, ~s("\\u001f\\b\\f\\n\\r\\t\\\\\\\"")} = + Canonical.bytes(<<31, 8, 12, 10, 13, 9, 92, 34>>) + + refute Canonical.bytes("Γ©") == Canonical.bytes("e\u0301") + end + + test "complete normalized envelope matches independent bytes and semantic field changes" do + request = DB.request(@host, @workspace) + {:ok, original} = Request.normalize(@owner, "key", request) + + assert original.bytes == + ~s({"action":"create","owner_id":"00000000-0000-4000-8000-000000000001","parameters":{"config":{"commit":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","repository_identity":"git@example.test:private-repository.git","submodules":[]},"host_id":"00000000-0000-4000-8000-000000000002"},"schema_version":1,"workspace_id":"00000000-0000-4000-8000-000000000003"}) + + assert original.hash == "cf080204edf198973786c3e5e503dfcc5af4e45caa0909e2a93784fbfa83224f" + + variants = [ + put_in(request, ["parameters", "host_id"], @owner), + put_in( + request, + ["parameters", "config", "repository_identity"], + "git@EXAMPLE.test:private-repository.git" + ), + put_in(request, ["parameters", "config", "commit"], String.duplicate("b", 40)), + put_in(request, ["parameters", "config", "submodules"], [ + %{"path" => "project", "commit" => String.duplicate("a", 40)} + ]), + %{request | "action" => "rebuild", "parameters" => %{"config" => DB.config()}}, + DB.request(@host, @workspace, "start"), + DB.request(@host, @workspace, "stop"), + DB.request(@host, @workspace, "remove_runtime") + ] + + for changed <- variants do + assert {:ok, normalized} = Request.normalize(@owner, "key", changed) + refute normalized.hash == original.hash + end + + {:ok, foreign} = Request.normalize(@host, "key", request) + refute foreign.hash == original.hash + end + + test "native canonical domain enforces exact types depth and integer limits" do + for invalid <- [1.0, :atom, {1}, %{atom: 1}, <<255>>, %{"bad" => <<237, 160, 128>>}] do + assert {:error, :invalid_json} = Canonical.bytes(invalid) + end + + depth128 = Enum.reduce(1..128, nil, fn _, nested -> [nested] end) + assert {:ok, _} = Canonical.bytes(depth128) + assert {:error, :invalid_json} = Canonical.bytes([depth128]) + assert {:ok, bytes} = Canonical.bytes(Integer.pow(10, 4299)) + assert byte_size(bytes) == 4300 + assert {:error, :invalid_json} = Canonical.bytes(Integer.pow(10, 4300)) + assert {:error, :invalid_json} = Canonical.bytes(-Integer.pow(10, 4300)) + assert {:ok, bytes} = Canonical.bytes(String.duplicate("x", 1_048_577)) + assert byte_size(bytes) == 1_048_579 + end + + test "normalization defaults and submodule ordering preserve semantic identity" do + request = DB.request(@host, @workspace) + bare = update_in(request, ["parameters", "config"], &Map.delete(&1, "submodules")) + assert {:ok, first} = Request.normalize(@owner, "same", request) + assert {:ok, second} = Request.normalize(@owner, "different-key", bare) + assert first.bytes == second.bytes + assert first.hash == second.hash + assert first.request["owner_id"] == @owner + + entries = [ + %{"path" => "z", "commit" => String.duplicate("b", 40)}, + %{"path" => "a", "commit" => String.duplicate("c", 64)} + ] + + left = put_in(request, ["parameters", "config", "submodules"], entries) + right = put_in(request, ["parameters", "config", "submodules"], Enum.reverse(entries)) + assert {:ok, a} = Request.normalize(@owner, "key", left) + assert {:ok, b} = Request.normalize(@owner, "key", right) + assert a.bytes == b.bytes + + for action <- ["start", "stop", "remove_runtime"] do + explicit = DB.request(@host, @workspace, action) + + assert Request.normalize(@owner, "key", explicit) == + Request.normalize(@owner, "key", Map.delete(explicit, "parameters")) + end + end + + test "native request rejects unknown fields actions nulls and invalid scopes" do + request = DB.request(@host, @workspace) + + bad = [ + Map.delete(request, "schema_version"), + %{request | "schema_version" => 1.0}, + Map.put(request, "owner_id", @owner), + Map.put(request, "timeout", 1), + %{request | "action" => "delete_data"}, + %{request | "action" => "unknown"}, + %{request | "parameters" => nil}, + %{request | "workspace_id" => String.upcase(@workspace <> "a")}, + put_in(request, ["parameters", "host_id"], "foreign"), + put_in(request, ["parameters", "config", "commit"], "main"), + put_in(request, ["parameters", "config", "submodules"], nil), + put_in(request, ["parameters", "config", "secret"], "canary"), + Map.put(DB.request(@host, @workspace, "stop"), "parameters", %{"delete_data" => true}) + ] + + for input <- bad, + do: assert({:error, :invalid_request} = Request.normalize(@owner, "key", input)) + + for key <- ["", "white space", "Γ©", String.duplicate("x", 129), nil], + do: assert({:error, :invalid_request} = Request.normalize(@owner, key, request)) + + assert {:ok, _} = Request.normalize(@owner, String.duplicate("x", 128), request) + assert {:error, :invalid_request} = Request.normalize("wrong-owner", "key", request) + + assert {:error, :invalid_request} = + Request.normalize(@owner, "key", ~s({"duplicate":1,"duplicate":2})) + + duplicate = List.duplicate(%{"path" => "a", "commit" => String.duplicate("a", 40)}, 2) + + assert {:error, :invalid_request} = + Request.normalize( + @owner, + "key", + put_in(request, ["parameters", "config", "submodules"], duplicate) + ) + + for path <- [".", "..", "a/../b", "/a", "a\\b", "a/.git/b", "a//b", "a\n"] do + entries = [%{"path" => path, "commit" => String.duplicate("a", 40)}] + + assert {:error, :invalid_request} = + Request.normalize( + @owner, + "key", + put_in(request, ["parameters", "config", "submodules"], entries) + ) + end + end + + test "native improper submodule lists return a fixed rejection" do + request = DB.request(@host, @workspace) + entry = %{"path" => "a", "commit" => String.duplicate("a", 40)} + + for entries <- [[entry | :bad_tail], [:bad | :tail]] do + assert {:error, :invalid_request} = + Request.normalize( + @owner, + "key", + put_in(request, ["parameters", "config", "submodules"], entries) + ) + end + + assert {:error, :invalid_json} = Canonical.bytes([1 | :bad_tail]) + end + + test "lexical repository identities preserve HTTPS SSH and scp without fetch claims" do + positives = [ + "HTTPS://Host:0443/Repo.git", + "ssh://git@Host:022/Repo.git", + "git@Host:Repo.git", + "git@[::1]:Repo.git", + "ssh://[2001:db8::1]/repo", + "https://host./", + "https://999.999/path", + "host:/absolute/path:part", + "host:path@name", + "https://host/%2fRepo" + ] + + negatives = [ + "", + "https://user:password@host/repo", + "https://user@host/repo", + "ssh://git:password@host/repo", + "https://host", + "ssh://host", + "git://host/repo", + "https://under_score/repo", + "https://a..b/repo", + "ssh://[:::]/repo", + "ssh://[fe80::1%eth0]/repo", + "ssh://host:0/repo", + "ssh://host:65536/repo", + "ssh://host:/repo", + "git@host:", + "host:path?secret", + "https://host/r#x", + "https://host/%xx", + "https://host/r\\x", + "https://host/a b", + "https://host/\u0085", + "https://host/\u3000", + <<255>> + ] + + for value <- positives, do: assert(RepositoryIdentity.valid?(value), inspect(value)) + for value <- negatives, do: refute(RepositoryIdentity.valid?(value), inspect(value)) + + hashes = + for origin <- [ + "https://HOST/repo", + "https://host/repo", + "https://host/repo.git", + "https://host:443/repo" + ] do + request = + put_in( + DB.request(@host, @workspace), + ["parameters", "config", "repository_identity"], + origin + ) + + {:ok, normalized} = Request.normalize(@owner, "key", request) + normalized.hash + end + + assert Enum.uniq(hashes) == hashes + end +end diff --git a/apps/control_plane/test/workharbor/foundation_test.exs b/apps/control_plane/test/workharbor/foundation_test.exs new file mode 100644 index 00000000..70c07303 --- /dev/null +++ b/apps/control_plane/test/workharbor/foundation_test.exs @@ -0,0 +1,1078 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.FoundationTest do + use ExUnit.Case, async: false + alias WorkHarbor.Foundation, as: F + alias WorkHarbor.FoundationDatabase, as: DB + alias WorkHarbor.Repo + + setup do + path = Path.join(System.tmp_dir!(), "workharbor-foundation-#{Ecto.UUID.generate()}.db") + pid = DB.open!(path) + {owner, host} = DB.fixture!() + + on_exit(fn -> + if Process.alive?(pid), do: GenServer.stop(pid) + for suffix <- ["", "-wal", "-shm"], do: File.rm(path <> suffix) + end) + + %{path: path, pid: pid, owner: owner, host: host} + end + + test "acceptance commits scoped intent reservations and redacted audit atomically", c do + assert {:ok, {:accepted, operation}} = F.accept(c.owner, "key", DB.request(c.host), "cli") + assert operation.status == "queued" + assert operation.intent_version == 1 + assert DB.count("persistent_storage") == 1 + assert DB.count("runtime_instances") == 1 + bindings = F.bindings(c.owner, c.host, operation.id) + assert bindings["candidate"]["generation"] == 1 + assert bindings["target"] == nil + + assert DB.sql( + "SELECT observed_state, provider_instance_id, effective_config, is_current FROM runtime_instances" + ).rows == [["pending", nil, nil, 0]] + + assert DB.sql("SELECT desired_state, intent_version, last_runtime_generation FROM workspaces").rows == + [["running", 1, 1]] + + assert DB.sql("SELECT event FROM audit_events WHERE operation_id = ? ORDER BY event", [ + operation.id + ]).rows == [["operation_accepted"], ["runtime_reserved"]] + + audit = DB.sql("SELECT metadata FROM audit_events").rows |> inspect() + refute audit =~ "private owner" + refute audit =~ "private-repository" + refute audit =~ "repository_identity" + assert {:error, :invalid_channel} = F.accept(c.owner, "other", DB.request(c.host), "canary") + end + + test "equal key replay precedes busy and state checks and changed payload conflicts", c do + request = DB.request(c.host) + {:ok, {:accepted, operation}} = F.accept(c.owner, "key", request, "cli") + assert {:ok, {:replayed, replay}} = F.accept(c.owner, "key", request, "browser") + assert replay.id == operation.id + changed = put_in(request, ["parameters", "config", "commit"], String.duplicate("b", 40)) + assert {:error, :idempotency_conflict} = F.accept(c.owner, "key", changed, "cli") + + assert {:error, :workspace_busy} = + F.accept(c.owner, "new", %{request | "action" => "stop", "parameters" => %{}}, "cli") + + assert DB.sql("SELECT intent_version FROM workspaces").rows == [[1]] + DB.finish!(c.owner, c.host, operation) + assert {:ok, {:replayed, terminal}} = F.accept(c.owner, "key", request, "cli") + assert terminal.status == "failed" + assert DB.count("operations") == 1 + assert DB.count("runtime_instances") == 1 + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event = 'operation_accepted'").rows == + [[1]] + end + + test "owner workspace key uniqueness and scope do not depend on repository owner", c do + {foreign, foreign_host} = DB.fixture!() + request = DB.request(c.host) + {:ok, {:accepted, operation}} = F.accept(c.owner, "same", request, "cli") + + assert {:error, :not_found} = + F.accept( + foreign, + "same", + %{request | "action" => "stop", "parameters" => %{}}, + "cli" + ) + + assert {:error, :not_found} = + F.accept(c.owner, "foreign-host", DB.request(foreign_host), "cli") + + assert {:error, :not_found} = F.claim(foreign, c.host, operation.id, 1) + assert {:error, :not_found} = F.claim(c.owner, foreign_host, operation.id, 1) + + assert {:ok, {:accepted, other_workspace}} = + F.accept(c.owner, "same", DB.request(c.host), "cli") + + refute other_workspace.id == operation.id + assert {:ok, {:accepted, _}} = F.accept(foreign, "same", DB.request(foreign_host), "cli") + end + + test "direct SQL constraints enforce scope active operation generations and retention", c do + {foreign, foreign_host} = DB.fixture!() + {:ok, {:accepted, operation}} = F.accept(c.owner, "key", DB.request(c.host), "cli") + runtime = F.bindings(c.owner, c.host, operation.id)["candidate"] + + assert_raise Exqlite.Error, fn -> + DB.sql("UPDATE workspaces SET owner_id = ? WHERE id = ?", [foreign, operation.workspace_id]) + end + + assert_raise Exqlite.Error, fn -> + DB.sql("UPDATE persistent_storage SET host_id = ?", [foreign_host]) + end + + assert_raise Exqlite.Error, fn -> + DB.sql("UPDATE runtime_instances SET host_id = ?", [foreign_host]) + end + + assert_raise Exqlite.Error, fn -> DB.sql("UPDATE operations SET owner_id = ?", [foreign]) end + assert_raise Exqlite.Error, fn -> DB.sql("UPDATE runtime_instances SET generation = 0") end + assert_raise Exqlite.Error, fn -> DB.sql("UPDATE workspaces SET intent_version = -1") end + + assert_raise Exqlite.Error, fn -> + DB.sql( + "INSERT INTO operations SELECT ?, owner_id, workspace_id, 'different', action, request_version, request_bytes, request_hash, intent_version + 1, status, execution_config_snapshot, terminal_result, inserted_at, updated_at FROM operations WHERE id = ?", + [Ecto.UUID.generate(), operation.id] + ) + end + + assert_raise Exqlite.Error, fn -> + DB.sql("DELETE FROM workspaces WHERE id = ?", [operation.workspace_id]) + end + + assert_raise Exqlite.Error, fn -> DB.sql("DELETE FROM owners WHERE id = ?", [c.owner]) end + + assert DB.sql("SELECT generation FROM runtime_instances WHERE id = ?", [runtime["id"]]).rows == + [[1]] + end + + test "audit failure rolls back all accepted graph and counter writes", c do + for event <- ["operation_accepted", "runtime_reserved"] do + DB.sql( + "CREATE TRIGGER reject_#{event} BEFORE INSERT ON audit_events WHEN NEW.event = '#{event}' BEGIN SELECT RAISE(ABORT, 'audit rejected'); END" + ) + + assert {:error, :persistence_failed} = F.accept(c.owner, "key", DB.request(c.host), "cli") + + for table <- [ + "workspaces", + "operations", + "persistent_storage", + "runtime_instances", + "operation_runtime_bindings" + ], + do: assert(DB.count(table) == 0) + + DB.sql("DROP TRIGGER reject_#{event}") + end + end + + test "start snapshots existing effective config and never allocates replacement", c do + request = DB.request(c.host) + {:ok, {:accepted, create}} = F.accept(c.owner, "create", request, "cli") + DB.finish!(c.owner, c.host, create) + start = DB.request(c.host, create.workspace_id, "start") + assert {:error, :runtime_unavailable} = F.accept(c.owner, "start", start, "cli") + target = DB.current!(create) + {:ok, {:accepted, operation}} = F.accept(c.owner, "start", start, "cli") + assert operation.execution_config_snapshot == DB.config() + assert F.bindings(c.owner, c.host, operation.id) == %{"target" => target, "candidate" => nil} + + assert {:error, :invalid_action} = + F.reserve_candidate(c.owner, c.host, operation.id, operation.intent_version) + + assert DB.sql("SELECT intent_version, last_runtime_generation FROM workspaces").rows == [ + [2, 1] + ] + + assert DB.count("runtime_instances") == 1 + end + + test "rebuild requested config never relabels existing effective config", c do + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + old = DB.current!(create) + DB.finish!(c.owner, c.host, create) + new_config = DB.config(String.duplicate("b", 40)) + + request = %{ + DB.request(c.host, create.workspace_id, "rebuild") + | "parameters" => %{"config" => new_config} + } + + {:ok, {:accepted, rebuild}} = F.accept(c.owner, "rebuild", request, "cli") + candidate = F.bindings(c.owner, c.host, rebuild.id)["candidate"] + assert candidate["generation"] == 2 + + assert {:ok, {:replayed, reserved}} = + F.reserve_candidate(c.owner, c.host, rebuild.id, rebuild.intent_version) + + assert reserved.id == candidate["id"] + + assert DB.sql("SELECT effective_config FROM runtime_instances WHERE id = ?", [old["id"]]).rows == + [[Jason.encode!(DB.config())]] + + assert DB.sql("SELECT effective_config FROM runtime_instances WHERE id = ?", [candidate["id"]]).rows == + [[nil]] + + DB.finish!(c.owner, c.host, rebuild) + + assert DB.sql("SELECT effective_config FROM runtime_instances WHERE id = ?", [old["id"]]).rows == + [[Jason.encode!(DB.config())]] + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event = 'runtime_reserved'").rows == [ + [2] + ] + + assert DB.count("persistent_storage") == 1 + end + + test "claim and results bind current intent exact runtime and immutable terminal outcome", c do + {:ok, {:accepted, operation}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + bindings = F.bindings(c.owner, c.host, operation.id) + success = %{"status" => "succeeded", "code" => "ok"} + failed = %{"status" => "failed", "code" => "operation_failed"} + + assert {:error, :invalid_transition} = + F.finish(c.owner, c.host, operation.id, 1, "queued", bindings, success) + + assert {:error, :stale_intent} = F.claim(c.owner, c.host, operation.id, 2) + assert {:ok, {:claimed, executing}} = F.claim(c.owner, c.host, operation.id, 1) + assert executing.status == "executing" + assert {:ok, {:already_executing, _}} = F.claim(c.owner, c.host, operation.id, 1) + bad = put_in(bindings, ["candidate", "generation"], 99) + + assert {:error, :runtime_mismatch} = + F.finish(c.owner, c.host, operation.id, 1, "executing", bad, success) + + assert {:ok, {:finished, terminal}} = + F.finish(c.owner, c.host, operation.id, 1, "executing", bindings, success) + + assert {:ok, {:replayed, replay}} = + F.finish(c.owner, c.host, operation.id, 1, "executing", bindings, success) + + assert replay.terminal_result == terminal.terminal_result + + assert {:error, :result_conflict} = + F.finish(c.owner, c.host, operation.id, 1, "executing", bindings, failed) + + assert {:error, :invalid_transition} = F.claim(c.owner, c.host, operation.id, 1) + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event = 'operation_claimed'").rows == [ + [1] + ] + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event = 'operation_terminal'").rows == + [[1]] + end + + test "same runtime generation cannot authorize stale operation results", c do + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + DB.current!(create) + DB.finish!(c.owner, c.host, create) + + {:ok, {:accepted, stop}} = + F.accept(c.owner, "stop", DB.request(c.host, create.workspace_id, "stop"), "cli") + + {:ok, {:claimed, _}} = F.claim(c.owner, c.host, stop.id, stop.intent_version) + + DB.sql("UPDATE workspaces SET intent_version = intent_version + 1 WHERE id = ?", [ + create.workspace_id + ]) + + assert {:error, :stale_intent} = + F.finish( + c.owner, + c.host, + stop.id, + stop.intent_version, + "executing", + F.bindings(c.owner, c.host, stop.id), + %{"status" => "succeeded", "code" => "ok"} + ) + + assert DB.sql("SELECT status FROM operations WHERE id = ?", [stop.id]).rows == [["executing"]] + assert DB.sql("SELECT last_runtime_generation FROM workspaces").rows == [[1]] + end + + test "confirmed runtime tombstone retains storage key history and terminal replay", c do + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + target = DB.current!(create) + DB.finish!(c.owner, c.host, create) + request = DB.request(c.host, create.workspace_id, "remove_runtime") + {:ok, {:accepted, remove}} = F.accept(c.owner, "remove", request, "cli") + + assert DB.sql("SELECT deleted_at FROM runtime_instances WHERE id = ?", [target["id"]]).rows == + [[nil]] + + {:ok, {:claimed, _}} = F.claim(c.owner, c.host, remove.id, remove.intent_version) + + observation = %{ + "runtime_id" => target["id"], + "generation" => target["generation"], + "observed_state" => "stopped", + "deleted" => true + } + + args = [ + c.owner, + c.host, + remove.id, + remove.intent_version, + "executing", + F.bindings(c.owner, c.host, remove.id), + %{"status" => "succeeded", "code" => "ok"}, + observation + ] + + assert {:ok, {:finished, _}} = apply(F, :finish, args) + + assert DB.sql( + "SELECT deleted_at IS NOT NULL, is_current FROM runtime_instances WHERE id = ?", + [target["id"]] + ).rows == [[1, 0]] + + assert DB.count("persistent_storage") == 1 + assert DB.count("workspaces") == 1 + assert {:ok, {:replayed, _}} = F.accept(c.owner, "remove", request, "browser") + + {:ok, {:accepted, newer}} = + F.accept(c.owner, "later-stop", DB.request(c.host, create.workspace_id, "stop"), "cli") + + assert newer.intent_version == 3 + assert {:ok, {:replayed, _}} = apply(F, :finish, args) + + assert {:error, :invalid_request} = + F.accept(c.owner, "data", %{request | "action" => "delete_data"}, "cli") + end + + test "independent writers serialize equal changed and competing submissions", c do + request = DB.request(c.host) + + results = + race(c.pid, fn index -> + F.accept(c.owner, "equal", request, if(index == 1, do: "cli", else: "browser")) + end) + + assert Enum.sort(Enum.map(results, fn {:ok, {kind, _}} -> kind end)) == [:accepted, :replayed] + assert results |> Enum.map(fn {:ok, {_, op}} -> op.id end) |> Enum.uniq() |> length() == 1 + assert DB.count("runtime_instances") == 1 + assert DB.count("operations") == 1 + {_, {_, operation}} = hd(results) + DB.finish!(c.owner, c.host, operation) + other = DB.request(c.host) + changed = put_in(other, ["parameters", "config", "commit"], String.duplicate("b", 40)) + + results = + race(c.pid, fn index -> + F.accept(c.owner, "changed", if(index == 1, do: other, else: changed), "cli") + end) + + assert Enum.count(results, &match?({:ok, {:accepted, _}}, &1)) == 1 + assert {:error, :idempotency_conflict} in results + fresh = DB.request(c.host) + results = race(c.pid, fn index -> F.accept(c.owner, "different-#{index}", fresh, "cli") end) + assert Enum.count(results, &match?({:ok, {:accepted, _}}, &1)) == 1 + assert {:error, :workspace_busy} in results + assert DB.count("operations") == 3 + end + + test "every independent writer uses foreign keys full synchronous and WAL", c do + parent = self() + + tasks = + for _ <- 1..5 do + Task.async(fn -> + Repo.put_dynamic_repo(c.pid) + + Repo.checkout(fn -> + send(parent, :checked_out) + + receive do + :measure -> :ok + end + + for pragma <- ["foreign_keys", "synchronous", "journal_mode"], + do: DB.sql("PRAGMA " <> pragma).rows + end) + end) + end + + for _ <- tasks, do: assert_receive(:checked_out, 5000) + for task <- tasks, do: send(task.pid, :measure) + for task <- tasks, do: assert(Task.await(task) == [[[1]], [[2]], [["wal"]]]) + end + + test "acceptance rejects nested transactions before any writes", c do + assert {:ok, {:error, :nested_transaction}} = + Repo.transaction( + fn -> + F.accept(c.owner, "nested", DB.request(c.host), "cli") + end, + mode: :immediate + ) + + assert DB.count("operations") == 0 + assert DB.count("workspaces") == 0 + end + + test "process crash before commit rolls back and after commit survives reopen", c do + for phase <- ["before", "after", "claimed"] do + path = c.path <> ".#{phase}" + pid = DB.open!(path) + {owner, host} = DB.fixture!() + GenServer.stop(pid) + Repo.put_dynamic_repo(c.pid) + workspace = Ecto.UUID.generate() + + code = """ + Logger.configure(level: :error) + {:ok, _} = Application.ensure_all_started(:ecto_sqlite3) + {:ok, _} = Application.ensure_all_started(:ecto_sql) + {:ok, repo} = WorkHarbor.Repo.start_link(name: nil, database: #{inspect(path)}, pool: DBConnection.ConnectionPool, synchronous: :full, foreign_keys: :on, journal_mode: :wal, log: false) + WorkHarbor.Repo.put_dynamic_repo(repo) + work = fn -> + {:ok, {:accepted, _}} = WorkHarbor.Foundation.accept(#{inspect(owner)}, "crash-key", #{inspect(DB.request(host, workspace))}, "cli") + end + wait = fn -> IO.puts("T005_READY"); Process.sleep(:infinity) end + if #{inspect(phase)} == "before" do + :telemetry.attach("crash-before-commit", [:work_harbor, :repo, :query], fn _, _, metadata, _ -> + if String.starts_with?(metadata.query, ~s(INSERT INTO "audit_events")) and match?({:ok, %{num_rows: 1}}, metadata.result) and "operation_accepted" in metadata.params do + IO.puts("T005_READY") + Process.sleep(:infinity) + end + end, nil) + work.() + else + work.() + if #{inspect(phase)} == "claimed" do + {:ok, operation} = WorkHarbor.Foundation.lookup(#{inspect(owner)}, #{inspect(workspace)}, "crash-key") + {:ok, {:claimed, _}} = WorkHarbor.Foundation.claim(#{inspect(owner)}, #{inspect(host)}, operation.id, operation.intent_version) + end + wait.() + end + """ + + args = + Enum.flat_map(:code.get_path(), fn path -> ["-pa", List.to_string(path)] end) ++ + ["-e", code] + + port = + Port.open({:spawn_executable, System.find_executable("elixir")}, [ + :binary, + :exit_status, + :stderr_to_stdout, + args: args + ]) + + try do + wait_ready(port, "") + {:os_pid, os_pid} = Port.info(port, :os_pid) + assert {_, 0} = System.cmd("kill", ["-KILL", Integer.to_string(os_pid)]) + assert_receive {^port, {:exit_status, _}}, 10_000 + reopened = DB.open!(path) + + try do + if phase == "before" do + for table <- [ + "workspaces", + "operations", + "persistent_storage", + "runtime_instances", + "operation_runtime_bindings" + ], + do: assert(DB.count(table) == 0) + else + assert DB.count("operations") == 1 + + assert {:ok, {:replayed, operation}} = + F.accept(owner, "crash-key", DB.request(host, workspace), "cli") + + assert operation.status == if(phase == "claimed", do: "executing", else: "queued") + assert DB.count("persistent_storage") == 1 + assert DB.count("runtime_instances") == 1 + + assert DB.sql("SELECT count(*) FROM audit_events WHERE operation_id IS NOT NULL").rows == + [[if(phase == "claimed", do: 3, else: 2)]] + end + after + GenServer.stop(reopened) + Repo.put_dynamic_repo(c.pid) + end + after + case Port.info(port, :os_pid) do + {:os_pid, alive} -> + System.cmd("kill", ["-KILL", Integer.to_string(alive)], stderr_to_stdout: true) + + receive do + {^port, {:exit_status, _}} -> :ok + after + 1000 -> :ok + end + + _ -> + :ok + end + + for suffix <- ["", "-wal", "-shm"], do: File.rm(path <> suffix) + end + end + end + + test "target observations cannot initially populate effective config", c do + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + target = F.bindings(c.owner, c.host, create.id)["candidate"] + DB.sql("UPDATE runtime_instances SET is_current = 1 WHERE id = ?", [target["id"]]) + DB.finish!(c.owner, c.host, create) + + for action <- ["stop", "remove_runtime"] do + {:ok, {:accepted, operation}} = + F.accept(c.owner, action, DB.request(c.host, create.workspace_id, action), "cli") + + {:ok, {:claimed, _}} = F.claim(c.owner, c.host, operation.id, operation.intent_version) + + observation = %{ + "runtime_id" => target["id"], + "generation" => 1, + "observed_state" => "stopped", + "effective_config" => DB.config() + } + + assert {:error, :runtime_mismatch} = + F.finish( + c.owner, + c.host, + operation.id, + operation.intent_version, + "executing", + F.bindings(c.owner, c.host, operation.id), + %{"status" => "succeeded", "code" => "ok"}, + observation + ) + + assert DB.sql( + "SELECT effective_config, observed_state FROM runtime_instances WHERE id = ?", + [target["id"]] + ).rows == [[nil, "pending"]] + + assert DB.sql("SELECT status, terminal_result FROM operations WHERE id = ?", [operation.id]).rows == + [["executing", nil]] + + assert DB.sql( + "SELECT count(*) FROM audit_events WHERE operation_id = ? AND event = 'operation_terminal'", + [operation.id] + ).rows == [[0]] + + DB.finish!(c.owner, c.host, %{operation | status: "executing"}) + end + end + + test "candidate binding cannot borrow another operation runtime", c do + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + target = DB.current!(create) + DB.finish!(c.owner, c.host, create) + + {:ok, {:accepted, stop}} = + F.accept(c.owner, "stop", DB.request(c.host, create.workspace_id, "stop"), "cli") + + assert_raise Exqlite.Error, fn -> + DB.sql( + "INSERT INTO operation_runtime_bindings (id, operation_id, workspace_id, runtime_id, role, inserted_at, updated_at) VALUES (?, ?, ?, ?, 'candidate', ?, ?)", + [ + Ecto.UUID.generate(), + stop.id, + stop.workspace_id, + target["id"], + DateTime.to_iso8601(DateTime.utc_now()), + DateTime.to_iso8601(DateTime.utc_now()) + ] + ) + end + end + + test "insert scope negatives reach foreign keys without update trigger masking", c do + {foreign, foreign_host} = DB.fixture!() + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + + assert_raise Exqlite.Error, ~r/FOREIGN KEY constraint failed/, fn -> + DB.sql( + "INSERT INTO workspaces SELECT ?, ?, host_id, desired_state, 0, 0, requested_config, inserted_at, updated_at FROM workspaces WHERE id = ?", + [Ecto.UUID.generate(), foreign, create.workspace_id] + ) + end + + assert_raise Exqlite.Error, ~r/FOREIGN KEY constraint failed/, fn -> + clone_operation!(create, owner: foreign) + end + + reservation = clone_operation!(create) + + assert_raise Exqlite.Error, ~r/FOREIGN KEY constraint failed/, fn -> + DB.sql( + "INSERT INTO runtime_instances SELECT ?, workspace_id, ?, ?, generation+1, NULL, 'pending', NULL, 0, NULL, inserted_at, updated_at FROM runtime_instances WHERE reservation_operation_id = ?", + [Ecto.UUID.generate(), foreign_host, reservation, create.id] + ) + end + + fresh = Ecto.UUID.generate() + + DB.sql( + "INSERT INTO workspaces SELECT ?, owner_id, host_id, 'stopped', 0, 0, requested_config, inserted_at, updated_at FROM workspaces WHERE id = ?", + [fresh, create.workspace_id] + ) + + fresh_op = clone_operation!(create, workspace: fresh, version: 1) + + assert_raise Exqlite.Error, ~r/FOREIGN KEY constraint failed/, fn -> + DB.sql( + "INSERT INTO persistent_storage SELECT ?, ?, ?, ?, NULL, NULL, inserted_at, updated_at FROM persistent_storage WHERE workspace_id = ?", + [Ecto.UUID.generate(), fresh, foreign_host, fresh_op, create.workspace_id] + ) + end + + assert DB.sql("PRAGMA foreign_key_check").rows == [] + end + + test "direct operation key and current runtime constraints remain enforced", c do + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + + assert_raise Exqlite.Error, ~r/UNIQUE constraint failed: operations.owner_id/, fn -> + clone_operation!(create, key: "create") + end + + old = DB.current!(create) + DB.finish!(c.owner, c.host, create) + + rebuild_request = %{ + DB.request(c.host, create.workspace_id, "rebuild") + | "parameters" => %{"config" => DB.config()} + } + + {:ok, {:accepted, rebuild}} = F.accept(c.owner, "rebuild", rebuild_request, "cli") + candidate = F.bindings(c.owner, c.host, rebuild.id)["candidate"] + + assert_raise Exqlite.Error, ~r/UNIQUE constraint failed/, fn -> + DB.sql("UPDATE runtime_instances SET is_current = 1 WHERE id = ?", [candidate["id"]]) + end + + assert DB.sql("SELECT id FROM runtime_instances WHERE is_current = 1").rows == [[old["id"]]] + + assert_raise Exqlite.Error, fn -> + DB.sql("DELETE FROM operations WHERE id = ?", [create.id]) + end + + assert_raise Exqlite.Error, fn -> DB.sql("DELETE FROM persistent_storage") end + end + + test "candidate effective config and terminal observation replay are immutable", c do + {:ok, {:accepted, operation}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + {:ok, {:claimed, _}} = F.claim(c.owner, c.host, operation.id, 1) + candidate = F.bindings(c.owner, c.host, operation.id)["candidate"] + + observation = %{ + "runtime_id" => candidate["id"], + "generation" => 1, + "observed_state" => "running", + "provider_instance_id" => "opaque-private-handle", + "effective_config" => DB.config() + } + + bad = %{observation | "effective_config" => DB.config(String.duplicate("b", 40))} + success = %{"status" => "succeeded", "code" => "ok"} + bindings = F.bindings(c.owner, c.host, operation.id) + + assert {:error, :runtime_mismatch} = + F.finish(c.owner, c.host, operation.id, 1, "executing", bindings, success, bad) + + assert DB.sql("SELECT effective_config, provider_instance_id FROM runtime_instances").rows == + [[nil, nil]] + + assert {:ok, {:finished, _}} = + F.finish( + c.owner, + c.host, + operation.id, + 1, + "executing", + bindings, + success, + observation + ) + + assert {:ok, {:replayed, _}} = + F.finish( + c.owner, + c.host, + operation.id, + 1, + "executing", + bindings, + success, + observation + ) + + assert {:error, :result_conflict} = + F.finish(c.owner, c.host, operation.id, 1, "executing", bindings, success, %{ + observation + | "provider_instance_id" => "changed" + }) + + audit = DB.sql("SELECT metadata, code FROM audit_events").rows |> inspect() + refute audit =~ "opaque-private-handle" + refute audit =~ "private-repository" + assert DB.sql("SELECT is_current FROM runtime_instances").rows == [[0]] + end + + test "later candidate reservation has atomic audit and replay allocates nothing", c do + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + DB.finish!(c.owner, c.host, create) + # Committed unreserved intent fixture exercises the guarded later-reservation API. + operation_id = clone_operation!(create, status: "queued") + DB.sql("UPDATE workspaces SET intent_version = 2 WHERE id = ?", [create.workspace_id]) + + DB.sql( + "CREATE TRIGGER fail_later_audit BEFORE INSERT ON audit_events WHEN NEW.event = 'runtime_reserved' BEGIN SELECT RAISE(ABORT, 'audit rejected'); END" + ) + + assert {:error, :persistence_failed} = F.reserve_candidate(c.owner, c.host, operation_id, 2) + assert DB.sql("SELECT last_runtime_generation FROM workspaces").rows == [[1]] + assert DB.count("runtime_instances") == 1 + DB.sql("DROP TRIGGER fail_later_audit") + assert {:ok, {:reserved, runtime}} = F.reserve_candidate(c.owner, c.host, operation_id, 2) + assert runtime.generation == 2 + assert {:ok, {:replayed, replay}} = F.reserve_candidate(c.owner, c.host, operation_id, 2) + assert replay.id == runtime.id + + assert DB.sql( + "SELECT count(*) FROM audit_events WHERE operation_id = ? AND event = 'runtime_reserved'", + [operation_id] + ).rows == [[1]] + end + + test "writer lock contention is bounded and never falsely accepted", c do + parent = self() + + holder = + Task.async(fn -> + Repo.put_dynamic_repo(c.pid) + + Repo.transaction( + fn -> + send(parent, :holding_write_lock) + + receive do + :release -> :ok + end + end, + mode: :immediate + ) + end) + + assert_receive :holding_write_lock, 5000 + start = System.monotonic_time(:millisecond) + + try do + assert {:error, :persistence_busy} = F.accept(c.owner, "locked", DB.request(c.host), "cli") + assert System.monotonic_time(:millisecond) - start < 5000 + after + send(holder.pid, :release) + Task.await(holder) + end + + assert DB.count("operations") == 0 + end + + test "binding generations require exact integer types and complete role shape", c do + {:ok, {:accepted, operation}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + {:ok, {:claimed, _}} = F.claim(c.owner, c.host, operation.id, 1) + bindings = F.bindings(c.owner, c.host, operation.id) + + bad = [ + put_in(bindings, ["candidate", "generation"], 1.0), + Map.put(bindings, "extra", nil), + Map.delete(bindings, "target"), + %{"target" => bindings["candidate"], "candidate" => nil} + ] + + for malformed <- bad do + assert {:error, :runtime_mismatch} = + F.finish(c.owner, c.host, operation.id, 1, "executing", malformed, %{ + "status" => "succeeded", + "code" => "ok" + }) + + assert DB.sql("SELECT status FROM operations WHERE id = ?", [operation.id]).rows == [ + ["executing"] + ] + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event = 'operation_terminal'").rows == + [[0]] + end + end + + test "lookup rejects malformed keys without query exceptions or data disclosure", c do + {:ok, {:accepted, operation}} = F.accept(c.owner, "key", DB.request(c.host), "cli") + + for key <- [nil, %{"secret" => "canary"}, 1, "", "Γ©", String.duplicate("x", 129)] do + assert {:error, :not_found} = F.lookup(c.owner, operation.workspace_id, key) + end + + assert {:ok, found} = F.lookup(c.owner, operation.workspace_id, "key") + assert found.id == operation.id + end + + test "audit identity references cannot mismatch host runtime or intent scope", c do + {:ok, other_host} = F.create_host(c.owner) + {:ok, {:accepted, operation}} = F.accept(c.owner, "one", DB.request(c.host), "cli") + {:ok, {:accepted, other}} = F.accept(c.owner, "two", DB.request(c.host), "cli") + foreign_runtime = F.bindings(c.owner, c.host, other.id)["candidate"] + + sql = + "INSERT INTO audit_events SELECT ?, owner_id, ?, workspace_id, operation_id, 'operation_claimed', action, ?, 'internal', 'executing', NULL, ?, ?, '{}', inserted_at, updated_at FROM audit_events WHERE operation_id = ? AND event = 'operation_accepted'" + + variants = [ + [other_host.id, 1, nil, nil], + [c.host, 1, foreign_runtime["id"], 1], + [c.host, 99, nil, nil], + [c.host, 1, F.bindings(c.owner, c.host, operation.id)["candidate"]["id"], 99] + ] + + for parts <- variants do + assert_raise Exqlite.Error, ~r/FOREIGN KEY constraint failed/, fn -> + DB.sql(sql, [Ecto.UUID.generate()] ++ parts ++ [operation.id]) + end + end + end + + test "native adapter errors and audit never expose request canaries", c do + request = + put_in( + DB.request(c.host), + ["parameters", "config", "repository_identity"], + "git@example.test:private-canary-value.git" + ) + + log = + ExUnit.CaptureLog.capture_log([level: :debug], fn -> + {:ok, {:accepted, _}} = F.accept(c.owner, "key", request, "cli") + + DB.sql( + "CREATE TRIGGER reject_canary BEFORE INSERT ON audit_events WHEN NEW.event = 'operation_accepted' BEGIN SELECT RAISE(ABORT, 'private-canary-value'); END" + ) + + assert {:error, :persistence_failed} = + F.accept(c.owner, "other", DB.request(c.host), "cli") + end) + + refute log =~ "private-canary-value" + refute log =~ "private owner" + audit = DB.sql("SELECT * FROM audit_events").rows |> inspect() + refute audit =~ "private-canary-value" + refute audit =~ "private owner" + end + + test "candidate rejects malformed effective config without any result mutation", c do + {:ok, {:accepted, operation}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + {:ok, {:claimed, _}} = F.claim(c.owner, c.host, operation.id, 1) + candidate = F.bindings(c.owner, c.host, operation.id)["candidate"] + + invalid = [ + false, + true, + 0, + "config", + [], + %{}, + %{"unexpected" => "private-canary"}, + Map.delete(DB.config(), "commit"), + %{DB.config() | "submodules" => [%{} | :bad_tail]} + ] + + for config <- invalid do + observation = %{ + "runtime_id" => candidate["id"], + "generation" => 1, + "observed_state" => "running", + "effective_config" => config + } + + assert {:error, :invalid_result} = + F.finish( + c.owner, + c.host, + operation.id, + 1, + "executing", + F.bindings(c.owner, c.host, operation.id), + %{"status" => "succeeded", "code" => "ok"}, + observation + ) + + assert DB.sql("SELECT observed_state, effective_config FROM runtime_instances").rows == [ + ["pending", nil] + ] + + assert DB.sql("SELECT status, terminal_result FROM operations").rows == [["executing", nil]] + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event = 'operation_terminal'").rows == + [[0]] + end + + observation = %{ + "runtime_id" => candidate["id"], + "generation" => 1, + "observed_state" => "running" + } + + for {field, malformed} <- [{"provider_instance_id", false}, {"deleted", nil}] do + assert {:error, :invalid_result} = + F.finish( + c.owner, + c.host, + operation.id, + 1, + "executing", + F.bindings(c.owner, c.host, operation.id), + %{"status" => "succeeded", "code" => "ok"}, + Map.put(observation, field, malformed) + ) + end + + assert DB.sql("SELECT observed_state, effective_config FROM runtime_instances").rows == [ + ["pending", nil] + ] + + assert DB.sql("SELECT status, terminal_result FROM operations").rows == [["executing", nil]] + + assert DB.sql("SELECT count(*) FROM audit_events WHERE event = 'operation_terminal'").rows == + [[0]] + + normalized = Map.put(observation, "effective_config", DB.config()) + defaulted = Map.put(observation, "effective_config", Map.delete(DB.config(), "submodules")) + + assert {:ok, {:finished, finished}} = + F.finish( + c.owner, + c.host, + operation.id, + 1, + "executing", + F.bindings(c.owner, c.host, operation.id), + %{"status" => "succeeded", "code" => "ok"}, + defaulted + ) + + assert finished.terminal_result["observation"]["effective_config"] == DB.config() + + assert {:ok, {:replayed, _}} = + F.finish( + c.owner, + c.host, + operation.id, + 1, + "executing", + F.bindings(c.owner, c.host, operation.id), + %{"status" => "succeeded", "code" => "ok"}, + normalized + ) + end + + test "target rejects malformed effective config and retains known revisions", c do + {:ok, {:accepted, create}} = F.accept(c.owner, "create", DB.request(c.host), "cli") + target = DB.current!(create) + DB.finish!(c.owner, c.host, create) + + {:ok, {:accepted, operation}} = + F.accept(c.owner, "stop", DB.request(c.host, create.workspace_id, "stop"), "cli") + + {:ok, {:claimed, _}} = F.claim(c.owner, c.host, operation.id, 2) + + for config <- [false, 0, [], "config", %{}, %{"unexpected" => "private-canary"}] do + observation = %{ + "runtime_id" => target["id"], + "generation" => 1, + "observed_state" => "stopped", + "effective_config" => config + } + + assert {:error, :invalid_result} = + F.finish( + c.owner, + c.host, + operation.id, + 2, + "executing", + F.bindings(c.owner, c.host, operation.id), + %{"status" => "succeeded", "code" => "ok"}, + observation + ) + + assert DB.sql("SELECT observed_state, effective_config FROM runtime_instances").rows == [ + ["running", Jason.encode!(DB.config())] + ] + + assert DB.sql("SELECT status, terminal_result FROM operations WHERE id = ?", [operation.id]).rows == + [["executing", nil]] + + assert DB.sql( + "SELECT count(*) FROM audit_events WHERE operation_id = ? AND event = 'operation_terminal'", + [operation.id] + ).rows == [[0]] + end + end + + defp clone_operation!(source, options \\ []) do + id = Ecto.UUID.generate() + status = Keyword.get(options, :status, "failed") + + terminal = + if status == "failed", + do: + Jason.encode!(%{ + "outcome" => %{"status" => "failed", "code" => "operation_failed"}, + "observation" => nil + }), + else: nil + + DB.sql( + "INSERT INTO operations SELECT ?, ?, ?, ?, action, request_version, request_bytes, request_hash, ?, ?, execution_config_snapshot, ?, inserted_at, updated_at FROM operations WHERE id = ?", + [ + id, + Keyword.get(options, :owner, source.owner_id), + Keyword.get(options, :workspace, source.workspace_id), + Keyword.get(options, :key, Ecto.UUID.generate()), + Keyword.get(options, :version, source.intent_version + 1), + status, + terminal, + source.id + ] + ) + + id + end + + defp race(repo, action) do + parent = self() + + tasks = + for index <- 1..2 do + Task.async(fn -> + Repo.put_dynamic_repo(repo) + send(parent, :ready) + + receive do + :go -> action.(index) + end + end) + end + + for _ <- tasks, do: assert_receive(:ready, 5000) + for task <- tasks, do: send(task.pid, :go) + Enum.map(tasks, &Task.await(&1, 10_000)) + end + + defp wait_ready(port, output, deadline \\ System.monotonic_time(:millisecond) + 20_000) do + remaining = max(deadline - System.monotonic_time(:millisecond), 0) + + receive do + {^port, {:data, bytes}} -> + combined = output <> bytes + if byte_size(combined) > 65_536, do: flunk("child output exceeded test bound") + + if "T005_READY" in String.split(combined, "\n"), + do: :ok, + else: wait_ready(port, combined, deadline) + + {^port, {:exit_status, status}} -> + flunk("child exited #{status}: #{output}") + after + remaining -> flunk("child never reached crash boundary: #{output}") + end + end +end diff --git a/apps/control_plane/test/workharbor/host_connections_test.exs b/apps/control_plane/test/workharbor/host_connections_test.exs new file mode 100644 index 00000000..9d78903f --- /dev/null +++ b/apps/control_plane/test/workharbor/host_connections_test.exs @@ -0,0 +1,426 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostConnectionsTest do + use ExUnit.Case, async: false + alias WorkHarbor.{Accounts, Hosts, HostFixture, HostConnections, Repo} + alias WorkHarbor.Hosts.Issuer + + setup do + dir = Path.join(HostFixture.physical_temp(), "whr-host-test-#{Ecto.UUID.generate()}") + File.mkdir!(dir) + File.chmod!(dir, 0o700) + previous = Application.get_env(:workharbor, :host_identity) + clock = Application.get_env(:workharbor, :clock) + pid = WorkHarbor.HostDatabase.open!(Path.join(dir, "state.db")) + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, token, _} = Accounts.login(owner, secret, "cli_owner") + {:ok, principal} = Accounts.authenticate(token) + issuer_dir = Path.join(dir, "issuer") + + on_exit(fn -> + if previous, + do: Application.put_env(:workharbor, :host_identity, previous), + else: Application.delete_env(:workharbor, :host_identity) + + if clock, + do: Application.put_env(:workharbor, :clock, clock), + else: Application.delete_env(:workharbor, :clock) + + if Process.alive?(pid), do: GenServer.stop(pid) + File.rm_rf!(dir) + end) + + assert {:ok, _} = Issuer.initialize(issuer_dir) + Application.put_env(:workharbor, :host_identity, issuer_dir: issuer_dir) + {csr, _} = HostFixture.csr() + {:ok, issued} = Hosts.issue_enrollment(principal, nil) + {:ok, enrolled} = Hosts.redeem(issued.code, csr) + {:ok, peer} = Hosts.resolve_identity(Base.decode64!(enrolled["certificate"]), :active) + {:ok, mono} = Agent.start_link(fn -> 0 end) + previous_mono = Application.get_env(:workharbor, :host_monotonic_clock) + Application.put_env(:workharbor, :host_monotonic_clock, fn -> Agent.get(mono, & &1) end) + + on_exit(fn -> + if previous_mono, + do: Application.put_env(:workharbor, :host_monotonic_clock, previous_mono), + else: Application.delete_env(:workharbor, :host_monotonic_clock) + end) + + server = start_supervised!({HostConnections, repo: pid}) + + %{ + dir: dir, + issuer_dir: issuer_dir, + principal: principal, + repo: pid, + peer: peer, + mono: mono, + server: server + } + end + + defp message(c, kind) do + m = %{ + "schema_version" => 1, + "kind" => kind, + "owner_id" => c.peer.owner_id, + "host_id" => c.peer.host_id + } + + if kind == "hello", do: Map.put(m, "capabilities", []), else: m + end + + defp connect(c) do + {:ok, ref} = HostConnections.open(c.peer, self()) + + assert {:ok, %{"kind" => "hello_ack"}} = + HostConnections.receive_hello(ref, message(c, "hello")) + + assert {:ok, %{"kind" => "heartbeat_ack"}} = + HostConnections.heartbeat(ref, message(c, "heartbeat")) + + ref + end + + defp runtime(c) do + {:ok, {:accepted, operation}} = + WorkHarbor.Foundation.accept( + c.peer.owner_id, + "connection-fixture", + WorkHarbor.FoundationDatabase.request(c.peer.host_id), + "internal" + ) + + binding = WorkHarbor.FoundationDatabase.current!(operation) + {operation, binding} + end + + test "authenticated heartbeat becomes stale exactly30seconds without changing history", c do + ref = connect(c) + {operation, binding} = runtime(c) + + assert :ok = + HostConnections.record_fresh_observation( + ref, + binding["id"], + binding["generation"], + operation.intent_version + ) + + assert {:ok, %{observed_state: "running"}} = + HostConnections.project_runtime(c.principal, binding["id"]) + + Agent.update(c.mono, fn _ -> 29999 end) + + assert {:ok, %{observed_state: "running"}} = + HostConnections.project_runtime(c.principal, binding["id"]) + + Agent.update(c.mono, fn _ -> 30000 end) + send(c.server, :tick) + + assert {:ok, %{observed_state: "unknown"}} = + HostConnections.project_runtime(c.principal, binding["id"]) + + assert_receive {:host_connection_close, :stale} + + assert Repo.get!(WorkHarbor.Foundation.RuntimeInstance, binding["id"]).observed_state == + "running" + + assert Repo.get!(WorkHarbor.Foundation.Operation, operation.id).status == "queued" + end + + test "reconnect and replaced socket cannot refresh old runtime annotation", c do + ref = connect(c) + {operation, binding} = runtime(c) + + assert :ok = + HostConnections.record_fresh_observation( + ref, + binding["id"], + binding["generation"], + operation.intent_version + ) + + next = connect(c) + assert_receive {:host_connection_close, :replaced} + assert {:error, :unauthorized_host} = HostConnections.heartbeat(ref, message(c, "heartbeat")) + + assert {:ok, %{observed_state: "unknown"}} = + HostConnections.project_runtime(c.principal, binding["id"]) + + assert :ok = + HostConnections.record_fresh_observation( + next, + binding["id"], + binding["generation"], + operation.intent_version + ) + + assert {:ok, %{observed_state: "running"}} = + HostConnections.project_runtime(c.principal, binding["id"]) + + assert {:error, :stale_observation} = + HostConnections.record_fresh_observation( + next, + binding["id"], + binding["generation"] + 1, + operation.intent_version + ) + + assert {:error, :stale_observation} = + HostConnections.record_fresh_observation( + next, + binding["id"], + binding["generation"], + operation.intent_version + 1 + ) + end + + test "revocation and open socket certificate expiry fence existing connections", c do + ref = connect(c) + assert :ok = Hosts.revoke(c.principal, c.peer.host_id) + assert {:error, :unauthorized_host} = HostConnections.heartbeat(ref, message(c, "heartbeat")) + assert {:error, :unauthorized_host} = HostConnections.open(c.peer, self()) + assert_receive {:host_connection_close, _} + end + + test "closed connection messages reject malformed scope fields versions and bounds", c do + valid = message(c, "heartbeat") + + for bad <- [ + Map.put(valid, "schema_version", 2), + Map.put(valid, "schema_version", 1.0), + Map.put(valid, "host_id", c.peer.owner_id), + Map.put(valid, "token", "SECRET_SENTINEL") + ] do + assert {:error, _} = + WorkHarbor.HostConnections.Protocol.decode( + Jason.encode!(bad), + c.peer.owner_id, + c.peer.host_id + ) + end + + assert {:error, _} = + WorkHarbor.HostConnections.Protocol.decode( + Jason.encode!(valid) <> String.duplicate(" ", 4096), + c.peer.owner_id, + c.peer.host_id + ) + + assert {:error, _} = + WorkHarbor.HostConnections.Protocol.decode( + ~s({"schema_version":1,"schema_version":1}), + c.peer.owner_id, + c.peer.host_id + ) + end + + test "late heartbeat at stale boundary cannot revive an existing socket", c do + ref = connect(c) + Agent.update(c.mono, fn _ -> 30000 end) + assert {:error, :unauthorized_host} = HostConnections.heartbeat(ref, message(c, "heartbeat")) + assert_receive {:host_connection_close, :stale} + end + + test "renewed lease and replaced connection fence delayed stale timers", c do + ref = connect(c) + old_lease = :sys.get_state(c.server).peers[elem(ref, 1)].lease + Agent.update(c.mono, fn _ -> 29000 end) + {:ok, _} = HostConnections.heartbeat(ref, message(c, "heartbeat")) + Agent.update(c.mono, fn _ -> 30000 end) + send(c.server, {:stale, elem(ref, 1), old_lease}) + assert {:ok, "connected"} = HostConnections.host_state(c.peer.host_id) + next = connect(c) + send(c.server, {:stale, elem(ref, 1), old_lease}) + assert {:ok, "connected"} = HostConnections.host_state(c.peer.host_id) + assert {:ok, _} = HostConnections.heartbeat(next, message(c, "heartbeat")) + end + + test "restart invalidates known observations without changing retained operation history", c do + ref = connect(c) + {operation, binding} = runtime(c) + + :ok = + HostConnections.record_fresh_observation( + ref, + binding["id"], + binding["generation"], + operation.intent_version + ) + + :ok = stop_supervised(HostConnections) + start_supervised!({HostConnections, repo: c.repo}) + + assert {:ok, %{observed_state: "unknown"}} = + HostConnections.project_runtime(c.principal, binding["id"]) + + assert Repo.get!(WorkHarbor.Foundation.RuntimeInstance, binding["id"]).observed_state == + "running" + + assert {:error, :unauthorized_host} = HostConnections.heartbeat(ref, message(c, "heartbeat")) + end + + test "open socket absolute certificate expiry rejects further traffic", c do + ref = connect(c) + Application.put_env(:workharbor, :clock, fn -> c.peer.expires_at end) + assert {:error, :unauthorized_host} = HostConnections.heartbeat(ref, message(c, "heartbeat")) + assert_receive {:host_connection_close, :identity_invalid} + assert {:error, :unauthorized_host} = HostConnections.open(c.peer, self()) + end + + test "failed persistence leaves conservative unknown and fixed failure", c do + ref = connect(c) + {operation, binding} = runtime(c) + + :ok = + HostConnections.record_fresh_observation( + ref, + binding["id"], + binding["generation"], + operation.intent_version + ) + + Hosts.sql( + "CREATE TRIGGER fail_liveness BEFORE UPDATE ON host_connection_slots BEGIN SELECT RAISE(ABORT,'SECRET_SENTINEL'); END" + ) + + assert {:error, :persistence_failed} = HostConnections.heartbeat(ref, message(c, "heartbeat")) + + assert {:ok, %{observed_state: "unknown"}} = + HostConnections.project_runtime(c.principal, binding["id"]) + end + + defp await_projection(pid, remaining) do + found = + Enum.any?(elem(Process.info(pid, :messages), 1), fn + {:"$gen_call", _, message} when is_tuple(message) -> elem(message, 0) == :project + _ -> false + end) + + cond do + found -> + :ok + + remaining == 0 -> + flunk("projection did not reach worker") + + true -> + Process.sleep(1) + await_projection(pid, remaining - 1) + end + end + + test "projection rechecks authoritative current intent inside worker", c do + ref = connect(c) + {operation, binding} = runtime(c) + + :ok = + HostConnections.record_fresh_observation( + ref, + binding["id"], + binding["generation"], + operation.intent_version + ) + + :sys.suspend(c.server) + + task = + Task.async(fn -> + Repo.put_dynamic_repo(c.repo) + HostConnections.project_runtime(c.principal, binding["id"]) + end) + + try do + await_projection(c.server, 1000) + WorkHarbor.FoundationDatabase.finish!(c.peer.owner_id, c.peer.host_id, operation) + + request = + WorkHarbor.FoundationDatabase.request(c.peer.host_id, operation.workspace_id, "stop") + + assert {:ok, {:accepted, _}} = + WorkHarbor.Foundation.accept(c.peer.owner_id, "newer-intent", request, "internal") + after + :sys.resume(c.server) + end + + assert {:ok, %{observed_state: "unknown"}} = Task.await(task) + end + + test "stale audit rollback reports fixed persistence failure", c do + ref = connect(c) + entry = :sys.get_state(c.server).peers[elem(ref, 1)] + + Hosts.sql( + "CREATE TRIGGER reject_stale_audit BEFORE INSERT ON audit_events WHEN NEW.event='host_connection_unknown' BEGIN SELECT RAISE(ABORT,'SECRET_SENTINEL'); END" + ) + + Agent.update(c.mono, fn _ -> 30000 end) + assert {:error, :persistence_failed} = HostConnections.mark_stale(ref, entry.lease) + end + + test "dedicated monotonic certificate expiry timer fences idle and replaced sockets", c do + ref = connect(c) + entry = :sys.get_state(c.server).peers[elem(ref, 1)] + assert is_reference(Map.get(entry, :expiry_timer)) + assert is_integer(Process.read_timer(entry.expiry_timer)) + send(c.server, {:expire, elem(ref, 1), entry.id}) + assert {:ok, "connected"} = HostConnections.host_state(c.peer.host_id) + next = connect(c) + assert_receive {:host_connection_close, :replaced} + assert Process.read_timer(entry.expiry_timer) == false + send(c.server, {:expire, elem(ref, 1), entry.id}) + assert {:ok, "connected"} = HostConnections.host_state(c.peer.host_id) + next_entry = :sys.get_state(c.server).peers[elem(next, 1)] + Agent.update(c.mono, fn _ -> next_entry.expiry end) + send(c.server, {:expire, elem(next, 1), next_entry.id}) + assert_receive {:host_connection_close, :identity_invalid} + assert {:ok, "unknown"} = HostConnections.host_state(c.peer.host_id) + end + + test "agent socket lifetime is fenced by owning connection worker death", c do + assert {:ok, state} = WorkHarborWeb.AgentSocket.init(c.peer) + assert is_reference(Map.get(state, :worker_monitor)) + monitor = state.worker_monitor + assert :ok = stop_supervised(HostConnections) + assert_receive {:DOWN, ^monitor, :process, _, _} = down + + assert {:stop, :normal, 1008, ^state} = + WorkHarborWeb.AgentSocket.handle_info(down, state) + end + + test "shared closed host connection fixtures match native validator" do + path = + Path.expand("../../specs/006-whr-cli/contracts/host-connection.fixtures.json", File.cwd!()) + + fixture = path |> File.read!() |> Jason.decode!() + + for c <- fixture["cases"] do + result = + WorkHarbor.HostConnections.Protocol.decode( + c["raw"], + fixture["owner_id"], + fixture["host_id"] + ) + + assert match?({:ok, _}, result) == c["accepted"], c["name"] + end + end + + test "rejected application data closes normally without crashing or echoing input", c do + assert {:ok, state} = WorkHarborWeb.AgentSocket.init(c.peer) + + raw = + Jason.encode!(%{ + schema_version: 1, + kind: "hello", + owner_id: c.peer.owner_id, + host_id: c.peer.host_id, + capabilities: [], + token: "SECRET_SENTINEL" + }) + + assert {:stop, :normal, 1008, ^state} = + WorkHarborWeb.AgentSocket.handle_in({raw, [opcode: :text]}, state) + end +end diff --git a/apps/control_plane/test/workharbor/host_identity_test.exs b/apps/control_plane/test/workharbor/host_identity_test.exs new file mode 100644 index 00000000..ba8beaee --- /dev/null +++ b/apps/control_plane/test/workharbor/host_identity_test.exs @@ -0,0 +1,485 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.HostIdentityTest do + use ExUnit.Case, async: false + alias WorkHarbor.{Accounts, Hosts, HostFixture, Repo} + alias WorkHarbor.Hosts.Issuer + + setup do + dir = Path.join(HostFixture.physical_temp(), "whr-host-test-#{Ecto.UUID.generate()}") + File.mkdir!(dir) + File.chmod!(dir, 0o700) + previous = Application.get_env(:workharbor, :host_identity) + clock = Application.get_env(:workharbor, :clock) + pid = WorkHarbor.HostDatabase.open!(Path.join(dir, "state.db")) + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + {:ok, token, _} = Accounts.login(owner, secret, "cli_owner") + {:ok, principal} = Accounts.authenticate(token) + issuer_dir = Path.join(dir, "issuer") + + on_exit(fn -> + if previous, + do: Application.put_env(:workharbor, :host_identity, previous), + else: Application.delete_env(:workharbor, :host_identity) + + if clock, + do: Application.put_env(:workharbor, :clock, clock), + else: Application.delete_env(:workharbor, :clock) + + if Process.alive?(pid), do: GenServer.stop(pid) + File.rm_rf!(dir) + end) + + assert {:ok, _} = Issuer.initialize(issuer_dir) + Application.put_env(:workharbor, :host_identity, issuer_dir: issuer_dir) + %{dir: dir, issuer_dir: issuer_dir, principal: principal, repo: pid, secret: secret} + end + + test "native issuer initializes exclusively and reloads same private identity", c do + assert {:ok, first} = Issuer.load(c.issuer_dir) + assert {:error, :already_initialized} = Issuer.initialize(c.issuer_dir) + assert {:ok, second} = Issuer.load(c.issuer_dir) + assert first.certificate == second.certificate + assert Bitwise.band(File.stat!(Path.join(c.issuer_dir, "issuer.json")).mode, 0o777) == 0o600 + end + + test "issuer refuses unsafe missing future and malformed state without replacement", c do + path = Path.join(c.issuer_dir, "issuer.json") + File.chmod!(path, 0o644) + assert {:error, :unsafe_identity} = Issuer.load(c.issuer_dir) + File.chmod!(path, 0o600) + File.write!(path, ~s({"schema_version":2,"private_key":"SECRET_SENTINEL"})) + assert {:error, :invalid_identity} = Issuer.load(c.issuer_dir) + assert {:error, :invalid_identity} = Issuer.load(Path.join(c.dir, "missing")) + end + + test "single use enrollment derives host scope and code never persists", c do + assert {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + assert {:ok, enrolled} = Hosts.redeem(issued.code, csr) + assert enrolled["host_id"] == issued.host_id + assert enrolled["owner_id"] == c.principal.owner_id + assert {:error, :invalid_enrollment} = Hosts.redeem(issued.code, csr) + + refute inspect(Repo.query!("SELECT * FROM host_enrollments", [], log: false).rows) =~ + issued.code + + refute inspect(Repo.all(WorkHarbor.Foundation.AuditEvent)) =~ issued.code + end + + test "enrollment exact expiry issues no identities", c do + now = Accounts.now() + Application.put_env(:workharbor, :clock, fn -> now end) + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + Application.put_env(:workharbor, :clock, fn -> now + 300 end) + assert {:error, :invalid_enrollment} = Hosts.redeem(issued.code, csr) + assert WorkHarbor.FoundationDatabase.count("host_identities") == 0 + end + + test "independent enrollment writers have exactly one committed winner", c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + parent = self() + + tasks = + for _ <- 1..2 do + Task.async(fn -> + Repo.put_dynamic_repo(c.repo) + send(parent, {:ready, self()}) + + receive do + :go -> Hosts.redeem(issued.code, csr) + end + end) + end + + for _ <- 1..2 do + assert_receive {:ready, _} + end + + Enum.each(tasks, &send(&1.pid, :go)) + results = Enum.map(tasks, &Task.await(&1, 5000)) + assert Enum.count(results, &match?({:ok, _}, &1)) == 1 + assert Enum.count(results, &(&1 == {:error, :invalid_enrollment})) == 1 + assert WorkHarbor.FoundationDatabase.count("host_identities") == 1 + end + + test "explicit host actions reject dashboard and cross owner before mutation", c do + {:ok, token, _} = Accounts.login(c.principal.owner_id, c.secret, "dashboard") + {:ok, dashboard} = Accounts.authenticate(token) + assert {:error, :forbidden} = Hosts.issue_enrollment(dashboard, nil) + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {:ok, foreign} = WorkHarbor.Foundation.create_owner("other") + {:ok, host} = WorkHarbor.Foundation.create_host(foreign.id) + assert {:error, :not_found} = Hosts.issue_enrollment(c.principal, host.id) + assert {:error, :not_found} = Hosts.revoke(c.principal, host.id) + assert {:ok, _} = Hosts.status(c.principal, issued.host_id) + end + + test "revocation tombstones host codes identities and repeated revoke", c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + {:ok, enrolled} = Hosts.redeem(issued.code, csr) + cert = Base.decode64!(enrolled["certificate"]) + assert {:ok, _} = Hosts.resolve_identity(cert, :active) + assert :ok = Hosts.revoke(c.principal, issued.host_id) + assert {:error, :unauthorized_host} = Hosts.resolve_identity(cert, :active) + assert {:error, :host_revoked} = Hosts.issue_enrollment(c.principal, issued.host_id) + assert :ok = Hosts.revoke(c.principal, issued.host_id) + assert {:ok, %{revoked: true}} = Hosts.status(c.principal, issued.host_id) + end + + test "staged rotation pending activation and lost ack preserve one identity", c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + {:ok, enrolled} = Hosts.redeem(issued.code, csr) + old = Base.decode64!(enrolled["certificate"]) + {:ok, peer} = Hosts.resolve_identity(old, :active) + {renewal, _} = HostFixture.csr(issued.host_id) + assert {:ok, pending} = Hosts.renew(peer, renewal) + assert {:ok, ^pending} = Hosts.renew(peer, renewal) + new = Base.decode64!(pending["certificate"]) + assert {:error, :unauthorized_host} = Hosts.resolve_identity(new, :active) + {:ok, replacement} = Hosts.resolve_identity(new, :activation) + assert :ok = Hosts.activate(replacement) + assert :ok = Hosts.activate(replacement) + assert {:error, :unauthorized_host} = Hosts.resolve_identity(old, :active) + assert {:ok, _} = Hosts.resolve_identity(new, :active) + assert :ok = Hosts.revoke(c.principal, issued.host_id) + assert {:error, :unauthorized_host} = Hosts.activate(replacement) + end + + test "CSR signature corruption and enrollment names cannot issue identity", c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + size = byte_size(csr) - 1 + <> = csr + assert {:error, :invalid_csr} = Hosts.redeem(issued.code, prefix <> <>) + {named, _} = HostFixture.csr(issued.host_id) + assert {:error, :invalid_csr} = Hosts.redeem(issued.code, named) + assert WorkHarbor.FoundationDatabase.count("host_identities") == 0 + end + + test "certificate expiry and response remain identical across UTC tick redemption and renewal", + c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + base = Accounts.now() + Process.put(:t008_tick, 0) + + Application.put_env(:workharbor, :clock, fn -> + n = Process.get(:t008_tick, 0) + Process.put(:t008_tick, n + 1) + base + n + end) + + {:ok, enrolled} = Hosts.redeem(issued.code, csr) + cert = Base.decode64!(enrolled["certificate"]) + assert Issuer.inspect_certificate(cert).not_after == enrolled["expires_at"] + {:ok, peer} = Hosts.resolve_identity(cert, :active) + {renewal, _} = HostFixture.csr(issued.host_id) + {:ok, pending} = Hosts.renew(peer, renewal) + + assert Issuer.inspect_certificate(Base.decode64!(pending["certificate"])).not_after == + pending["expires_at"] + end + + test "revoked tombstone scope cannot move clear delete or admit reenrollment", c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + :ok = Hosts.revoke(c.principal, issued.host_id) + {:ok, other} = WorkHarbor.Foundation.create_host(c.principal.owner_id) + + for {statement, args} <- [ + {"UPDATE host_security SET host_id=? WHERE host_id=?", [other.id, issued.host_id]}, + {"UPDATE host_security SET revoked_at=NULL WHERE host_id=?", [issued.host_id]}, + {"DELETE FROM host_security WHERE host_id=?", [issued.host_id]} + ] do + assert_raise Exqlite.Error, fn -> Repo.query!(statement, args, log: false) end + end + + assert {:error, :host_revoked} = Hosts.issue_enrollment(c.principal, issued.host_id) + end + + test "CSR and private issuer DER suffixes reject before code consumption", c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + + for invalid <- [csr <> <<0>>, csr <> csr] do + assert {:error, :invalid_csr} = Hosts.redeem(issued.code, invalid) + end + + assert {:ok, _} = Hosts.redeem(issued.code, csr) + path = Path.join(c.issuer_dir, "issuer.json") + state = Jason.decode!(File.read!(path)) + + for field <- ["certificate", "private_key"] do + bad = + Map.update!(state, field, fn value -> Base.encode64(Base.decode64!(value) <> <<0>>) end) + + File.write!(path, Jason.encode!(bad)) + assert {:error, :invalid_identity} = Issuer.load(c.issuer_dir) + end + end + + test "issuer refuses final and ancestor symlinks before initialization writes", c do + real = Path.join(c.dir, "real") + File.mkdir!(real) + File.chmod!(real, 0o700) + alias_path = Path.join(c.dir, "alias") + File.ln_s!(real, alias_path) + {:ok, _} = Issuer.initialize(Path.join(real, "issuer")) + assert {:error, :unsafe_identity} = Issuer.load(Path.join(alias_path, "issuer")) + assert {:error, :unsafe_identity} = Issuer.initialize(Path.join(alias_path, "fresh")) + refute File.exists?(Path.join(real, "fresh")) + end + + test "native leaf and issuer profiles reject extra purposes critical and duplicate extensions", + c do + {:ok, issuer} = Issuer.load(c.issuer_dir) + {csr, _} = HostFixture.csr() + host = Ecto.UUID.generate() + {:ok, leaf} = Issuer.issue(issuer, csr, host, :enrollment) + value = :public_key.pkix_decode_cert(leaf, :otp) |> elem(1) + extensions = elem(value, 10) + + for changed <- [ + extensions ++ [{:Extension, {1, 2, 3, 4}, true, <<5, 0>>}], + extensions ++ [{:Extension, {2, 5, 29, 37}, false, [{1, 3, 6, 1, 5, 5, 7, 3, 1}]}] + ] do + bad = :public_key.pkix_sign(put_elem(value, 10, changed), issuer.key) + accepted = Issuer.valid_leaf?(bad, issuer, host, Accounts.now()) + refute accepted + end + + ca = :public_key.pkix_decode_cert(issuer.certificate, :otp) |> elem(1) + + bad_ca = + :public_key.pkix_sign( + put_elem( + ca, + 10, + elem(ca, 10) ++ [{:Extension, {2, 5, 29, 37}, false, [{1, 3, 6, 1, 5, 5, 7, 3, 2}]}] + ), + issuer.key + ) + + accepted = Issuer.valid_leaf?(leaf, %{issuer | certificate: bad_ca}, host, Accounts.now()) + refute accepted + end + + test "issuer startup rejects signed critical path length and duration profile variants", c do + {:ok, issuer} = Issuer.load(c.issuer_dir) + ca = :public_key.pkix_decode_cert(issuer.certificate, :otp) |> elem(1) + path = Path.join(c.issuer_dir, "issuer.json") + state = Jason.decode!(File.read!(path)) + + variants = [ + elem(ca, 10) ++ [{:Extension, {1, 2, 3, 4}, true, <<5, 0>>}], + Enum.map(elem(ca, 10), fn + {:Extension, {2, 5, 29, 19}, critical, _} -> + {:Extension, {2, 5, 29, 19}, critical, {:BasicConstraints, true, 0}} + + ext -> + ext + end) + ] + + for extensions <- variants do + signed = :public_key.pkix_sign(put_elem(ca, 10, extensions), issuer.key) + File.write!(path, Jason.encode!(Map.put(state, "certificate", Base.encode64(signed)))) + result = Issuer.load(c.issuer_dir) + rejected = result == {:error, :invalid_identity} + assert rejected + end + + File.write!(path, Jason.encode!(state)) + {csr, _} = HostFixture.csr() + host = Ecto.UUID.generate() + {:ok, leaf} = Issuer.issue(issuer, csr, host, :enrollment) + tbs = :public_key.pkix_decode_cert(leaf, :otp) |> elem(1) + {:Validity, start, _} = elem(tbs, 5) + + stop = + {:generalTime, + (Accounts.now() + 3600) + |> DateTime.from_unix!() + |> Calendar.strftime("%Y%m%d%H%M%SZ") + |> String.to_charlist()} + + short = :public_key.pkix_sign(put_elem(tbs, 5, {:Validity, start, stop}), issuer.key) + accepted = Issuer.valid_leaf?(short, issuer, host, Accounts.now()) + refute accepted + end + + test "signed oversized issuer is refused before enrollment code consumption", c do + {:ok, issuer} = Issuer.load(c.issuer_dir) + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + path = Path.join(c.issuer_dir, "issuer.json") + bytes = File.read!(path) + original = Jason.decode!(bytes) + ca = :public_key.pkix_decode_cert(issuer.certificate, :otp) |> elem(1) + extensions = elem(ca, 10) ++ [{:Extension, {1, 2, 3, 4}, false, :binary.copy(<<0>>, 12000)}] + oversized = :public_key.pkix_sign(put_elem(ca, 10, extensions), issuer.key) + assert byte_size(oversized) > 4096 + File.write!(path, Jason.encode!(Map.put(original, "certificate", Base.encode64(oversized)))) + {csr, _} = HostFixture.csr() + assert elem(Issuer.load(c.issuer_dir), 0) == :error + refute Issuer.valid_issuer?(oversized) + assert {:error, _} = Hosts.redeem(issued.code, csr) + + assert Hosts.one("SELECT consumed_at FROM host_enrollments WHERE host_id=?", [issued.host_id])[ + "consumed_at" + ] == nil + + assert Hosts.one("SELECT count(*) AS n FROM host_identities WHERE host_id=?", [issued.host_id])[ + "n" + ] == 0 + + File.write!(path, bytes) + assert {:ok, response} = Hosts.redeem(issued.code, csr) + assert byte_size(Jason.encode!(response)) <= 16384 + end + + test "serialized owner revocation wins concurrent issuance redemption renewal and activation", + c do + for phase <- [:issue, :redeem, :renew, :activate] do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + + peer = + if phase in [:renew, :activate] do + {:ok, enrolled} = Hosts.redeem(issued.code, csr) + {:ok, active} = Hosts.resolve_identity(Base.decode64!(enrolled["certificate"]), :active) + active + end + + {renewal, _} = HostFixture.csr(issued.host_id) + + pending = + if phase == :activate do + {:ok, staged} = Hosts.renew(peer, renewal) + + {:ok, pending} = + Hosts.resolve_identity(Base.decode64!(staged["certificate"]), :activation) + + pending + end + + parent = self() + + job = fn fun -> + Task.async(fn -> + Repo.put_dynamic_repo(c.repo) + send(parent, {:writer_ready, self()}) + + receive do + :write -> fun.() + after + 2000 -> raise "writer barrier timeout" + end + end) + end + + contender = + job.(fn -> + case phase do + :issue -> Hosts.issue_enrollment(c.principal, issued.host_id) + :redeem -> Hosts.redeem(issued.code, csr) + :renew -> Hosts.renew(peer, renewal) + :activate -> Hosts.activate(pending) + end + end) + + revoker = job.(fn -> Hosts.revoke(c.principal, issued.host_id) end) + + for _ <- 1..2 do + assert_receive {:writer_ready, _}, 2000 + end + + send(contender.pid, :write) + send(revoker.pid, :write) + assert :ok = Task.await(revoker, 5000) + result = Task.await(contender, 5000) + assert result == :ok or elem(result, 0) in [:ok, :error] + assert {:ok, %{revoked: true}} = Hosts.status(c.principal, issued.host_id) + + assert Hosts.one( + "SELECT count(*) AS n FROM host_identities WHERE host_id=? AND status IN ('active','pending')", + [issued.host_id] + )["n"] == 0 + + assert Hosts.one( + "SELECT count(*) AS n FROM host_enrollments WHERE host_id=? AND revoked_at IS NULL", + [issued.host_id] + )["n"] == 0 + + assert elem(Hosts.redeem(issued.code, csr), 0) == :error + if peer, do: refute(Hosts.current_peer?(peer)) + if pending, do: refute(Hosts.current_peer?(pending, :activation)) + end + end + + test "enrollment audit failure rolls back consumption identity and active fingerprint", c do + {:ok, issued} = Hosts.issue_enrollment(c.principal, nil) + {csr, _} = HostFixture.csr() + + Hosts.sql( + "CREATE TRIGGER refuse_enrollment_audit BEFORE INSERT ON audit_events WHEN NEW.event='host_enrolled' BEGIN SELECT RAISE(ABORT,'SECRET_SENTINEL'); END" + ) + + assert {:error, :persistence_failed} = Hosts.redeem(issued.code, csr) + + assert Hosts.one("SELECT consumed_at FROM host_enrollments WHERE host_id=?", [issued.host_id])[ + "consumed_at" + ] == nil + + assert Hosts.one("SELECT count(*) AS n FROM host_identities WHERE host_id=?", [issued.host_id])[ + "n" + ] == 0 + + assert Hosts.one("SELECT identity_fingerprint FROM hosts WHERE id=?", [issued.host_id])[ + "identity_fingerprint" + ] == nil + + Hosts.sql("DROP TRIGGER refuse_enrollment_audit") + assert {:ok, _} = Hosts.redeem(issued.code, csr) + end + + test "native issuer persistence faults return fixed errors and restart only complete exclusive state", + c do + for stage <- [ + :file_open, + :write, + {:file, :sync}, + {:file, :close}, + :link, + :parent_open, + {:parent, :sync}, + {:parent, :close} + ] do + dir = Path.join(c.dir, "fault-#{Ecto.UUID.generate()}") + Process.put({WorkHarbor.Hosts.PrivateFile, :test_io}, WorkHarbor.HostFileFault) + Process.put({WorkHarbor.HostFileFault, :stage}, stage) + + try do + assert {:error, :persistence_failed} = Issuer.initialize(dir) + after + Process.delete({WorkHarbor.Hosts.PrivateFile, :test_io}) + Process.delete({WorkHarbor.HostFileFault, :stage}) + end + + if stage in [:parent_open, {:parent, :sync}, {:parent, :close}] do + assert {:ok, first} = Issuer.load(dir) + assert {:error, :already_initialized} = Issuer.initialize(dir) + assert {:ok, second} = Issuer.load(dir) + assert first.certificate == second.certificate and first.key == second.key + else + refute File.exists?(Path.join(dir, "issuer.json")) + assert {:ok, _} = Issuer.initialize(dir) + end + + assert File.ls!(dir) == ["issuer.json"] + end + end +end diff --git a/apps/control_plane/test/workharbor/host_protocol_test.exs b/apps/control_plane/test/workharbor/host_protocol_test.exs new file mode 100644 index 00000000..79bdfdd4 --- /dev/null +++ b/apps/control_plane/test/workharbor/host_protocol_test.exs @@ -0,0 +1,435 @@ +# SPDX-License-Identifier: EUPL-1.2 +defmodule WorkHarbor.HostProtocolTest do + use ExUnit.Case, async: true + alias WorkHarbor.HostProtocol, as: P + alias WorkHarbor.HostProtocol.{Peer, Snapshot, Context} + defp id(n), do: "00000000-0000-0000-0000-" <> String.pad_leading(to_string(n), 12, "0") + + defp vectors do + raw = File.read!(Path.expand("../../../../contracts/host-protocol/vectors.json", __DIR__)) + + assert Base.encode16(:crypto.hash(:sha256, raw), case: :lower) == + "dedc629e6989fdfaaad8053fe14a0680b692495fdfff88eb1f4e5a29c534d66d" + + Jason.decode!(raw) + end + + defp config, + do: %{ + "repository_identity" => "git@example.test:team/workspace.git", + "commit" => String.duplicate("a", 40), + "submodules" => [] + } + + defp command do + %{ + "schema_version" => 1, + "kind" => "command", + "command_id" => id(5), + "owner_id" => id(1), + "host_id" => id(2), + "workspace_id" => id(3), + "operation_id" => id(4), + "idempotency_key" => "create:1", + "request_sha256" => vectors()["request_sha256"], + "intent_version" => 1, + "request" => %{ + "schema_version" => 1, + "workspace_id" => id(3), + "action" => "create", + "parameters" => %{"host_id" => id(2), "config" => config()} + }, + "bindings" => %{"target" => nil, "candidate" => %{"runtime_id" => id(6), "generation" => 1}}, + "execution_config" => nil, + "approval_ref" => id(7) + } + end + + defp context do + %Context{ + peer: %Peer{kind: :control_plane, owner_id: id(1), host_id: id(2)}, + snapshot: %Snapshot{ + command: command(), + request_bytes: vectors()["request_bytes"], + request_hash: vectors()["request_sha256"], + native_bindings: %{"target" => nil, "candidate" => %{"id" => id(6), "generation" => 1}}, + current_intent: 1, + status: "queued", + runtimes: %{id(6) => %{provider_id: nil, effective_config: nil}} + } + } + end + + defp result, + do: + command() + |> Map.drop(["request", "execution_config", "approval_ref"]) + |> Map.put("kind", "result") + |> Map.put("outcome", %{"status" => "succeeded", "code" => "ok"}) + + defp observation, do: %{"runtime_id" => id(6), "generation" => 1, "observed_state" => "running"} + + defp host(c), + do: %{c | peer: %{c.peer | kind: :host}, snapshot: %{c.snapshot | status: "executing"}} + + test "independent shared raw vectors preserve codes and dispositions" do + for v <- vectors()["cases"] do + case P.decode(v["raw"], context()) do + {:ok, g} -> + assert v["code"] == "ok", v["id"] + assert g.disposition == v["disposition"], v["id"] + + {:error, code} -> + assert Atom.to_string(code) == v["code"], v["id"] + end + end + end + + test "independent canonical anchors separate request hash and command identity" do + assert {:ok, g} = P.validate(command(), context()) + assert g.canonical == vectors()["command_canonical"] + + assert Base.encode16(:crypto.hash(:sha256, g.canonical), case: :lower) == + vectors()["command_sha256"] + + refute g.canonical == vectors()["request_bytes"] + assert g.native_bindings["candidate"] == %{"id" => id(6), "generation" => 1} + end + + test "native types exact counters and improper lists reject" do + for bad <- [nil, true, 1, 1.0, [], {:tuple}, self(), %URI{}], + do: assert(match?({:error, _}, P.validate(bad, context()))) + + for bad <- [true, 1.0, "1", 0, 9_223_372_036_854_775_808] do + assert {:error, :invalid_message} = + P.validate(Map.put(command(), "intent_version", bad), context()) + end + + bad = put_in(command(), ["request", "parameters", "config", "submodules"], [%{} | :bad_tail]) + assert {:error, :invalid_message} = P.validate(bad, context()) + end + + test "trusted peer and missing expectation reject before lookup disclosure" do + bad = context() + + bad = %{ + bad + | peer: %{bad.peer | owner_id: id(9)}, + snapshot: %{bad.snapshot | command: %{"secret" => "private-canary"}} + } + + assert {:error, :unauthorized_scope} = P.validate(command(), bad) + + for peer <- [ + nil, + %Peer{kind: :owner, owner_id: id(1), host_id: id(2)}, + %Peer{kind: :control_plane, owner_id: id(9), host_id: id(2)} + ] do + assert {:error, :unauthorized_scope} = + P.validate(command(), %Context{peer: peer, snapshot: nil}) + end + + assert {:error, :unauthorized_scope} = P.validate(command(), %Context{peer: context().peer}) + end + + test "pending replay and executing command never permit another effect" do + c = context() + c = %{c | snapshot: %{c.snapshot | command_bytes: vectors()["command_canonical"]}} + assert {:ok, %{disposition: "pending_lookup"}} = P.validate(command(), c) + + assert {:ok, %{disposition: "reconciliation_required"}} = + P.validate(command(), %{c | snapshot: %{c.snapshot | status: "executing"}}) + end + + test "completed historical command replay ignores newer intent" do + c = context() + + c = %{ + c + | snapshot: %{ + c.snapshot + | status: "succeeded", + current_intent: 2, + command_bytes: vectors()["command_canonical"] + } + } + + assert {:ok, %{disposition: "completed_lookup"}} = P.validate(command(), c) + end + + test "changed replay and same runtime stale intent reject" do + c = context() + + assert {:error, :identity_conflict} = + P.validate(command(), %{c | snapshot: %{c.snapshot | command_bytes: "changed"}}) + + assert {:error, :stale_intent} = + P.validate(command(), %{c | snapshot: %{c.snapshot | current_intent: 2}}) + end + + test "observation omission and optional defaults preserve native nil" do + c = host(context()) + m = Map.put(result(), "observation", observation()) + assert {:ok, g} = P.validate(m, c) + + assert g.native_result["observation"] == + Map.merge(observation(), %{ + "provider_instance_id" => nil, + "effective_config" => nil, + "deleted" => false + }) + + assert {:ok, %{native_result: %{"observation" => nil}}} = P.validate(result(), c) + assert {:error, :invalid_message} = P.validate(Map.put(result(), "observation", nil), c) + end + + test "malformed observation values never become null" do + for bad <- [ + false, + true, + 0, + "config", + [], + %{}, + %{"secret" => "private-canary"}, + %{config() | "submodules" => [%{} | :bad_tail]} + ] do + m = Map.put(result(), "observation", Map.put(observation(), "effective_config", bad)) + assert {:error, :invalid_message} = P.validate(m, host(context())) + end + + for {field, bad} <- [{"provider_instance_id", false}, {"deleted", nil}, {"generation", 1.0}] do + assert {:error, :invalid_message} = + P.validate( + Map.put(result(), "observation", Map.put(observation(), field, bad)), + host(context()) + ) + end + end + + test "result status and immutable terminal replay are guarded" do + c = context() + c = %{c | peer: %{c.peer | kind: :host}} + assert {:error, :invalid_status} = P.validate(result(), c) + c = host(c) + assert {:ok, g} = P.validate(result(), c) + + c = %{ + c + | snapshot: %{ + c.snapshot + | status: "succeeded", + current_intent: 2, + terminal_result: g.native_result + } + } + + assert {:ok, %{disposition: "completed_lookup"}} = P.validate(result(), c) + m = Map.put(result(), "outcome", %{"status" => "failed", "code" => "operation_failed"}) + assert {:error, :identity_conflict} = P.validate(m, c) + end + + test "runtime scope provider config and removal observations are guarded" do + for variant <- [:runtime, :provider, :config, :deleted] do + c = host(context()) + o = observation() + + {c, o} = + case variant do + :runtime -> + {c, Map.put(o, "runtime_id", id(9))} + + :provider -> + {%{ + c + | snapshot: %{ + c.snapshot + | runtimes: %{id(6) => %{provider_id: "known", effective_config: nil}} + } + }, Map.put(o, "provider_instance_id", "changed")} + + :config -> + {c, + Map.put(o, "effective_config", %{config() | "commit" => String.duplicate("b", 40)})} + + :deleted -> + {c, Map.put(o, "deleted", true)} + end + + assert match?({:error, _}, P.validate(Map.put(result(), "observation", o), c)) + end + end + + test "result closed shape excludes command only payload fields" do + for field <- ["request", "execution_config", "approval_ref"], + do: + assert( + {:error, :invalid_message} == + P.validate(Map.put(result(), field, nil), host(context())) + ) + end + + test "native canonical budget aborts before emission and redacts canaries" do + for text <- [String.duplicate("x", 1_048_576), String.duplicate("\\", 524_288)] do + m = + put_in( + command(), + ["request", "parameters", "config", "repository_identity"], + "git@example.test:" <> text + ) + + assert {:error, :transport_refused} = P.validate(m, context()) + end + + assert {:error, :invalid_message} = + P.validate(Map.put(command(), "secret", "private-canary"), context()) + end + + test "raw size wins invalid Unicode and native depth is bounded" do + assert {:error, :input_limit} = + P.decode(<<255>> <> String.duplicate(" ", 1_048_576), context()) + + deep = Enum.reduce(1..129, nil, fn _, v -> [v] end) + assert {:error, :invalid_message} = P.validate(deep, context()) + end + + test "provider sentinel remains untouched for rejection and lookup" do + for v <- vectors()["cases"], v["code"] != "ok" do + case P.decode(v["raw"], context()) do + {:ok, %{disposition: "new_eligible"}} -> send(self(), :provider) + _ -> :ok + end + end + + sentinel = fn value, c -> + case P.validate(value, c) do + {:ok, %{disposition: "new_eligible"}} -> send(self(), :provider) + _ -> :ok + end + end + + sentinel.(Map.put(command(), "argv", ["sh"]), context()) + c = context() + + sentinel.(command(), %{ + c + | snapshot: %{c.snapshot | command_bytes: vectors()["command_canonical"]} + }) + + refute_received :provider + end + + test "every lifecycle branch and new intent on existing runtime validate" do + for a <- vectors()["actions"] do + m = Jason.decode!(a["command_canonical"]) + c = context() + + c = %{ + c + | snapshot: %{ + c.snapshot + | command: m, + request_bytes: a["request_bytes"], + request_hash: a["request_sha256"], + native_bindings: a["native_bindings"], + current_intent: 2, + runtimes: %{ + id(6) => %{provider_id: nil, effective_config: config()}, + id(8) => %{provider_id: nil, effective_config: nil} + } + } + } + + assert {:ok, %{disposition: "new_eligible"}} = P.decode(a["command_canonical"], c) + + r = + m + |> Map.drop(["request", "execution_config", "approval_ref"]) + |> Map.put("kind", "result") + |> Map.put("outcome", %{"status" => "succeeded", "code" => "ok"}) + + o = %{ + "runtime_id" => id(6), + "generation" => 1, + "observed_state" => "stopped", + "effective_config" => config() + } + + o = + case a["action"] do + "start" -> + Map.put(o, "observed_state", "running") + + "rebuild" -> + %{o | "runtime_id" => id(8), "generation" => 2, "observed_state" => "running"} + + "remove_runtime" -> + Map.put(o, "deleted", true) + + _ -> + o + end + + assert {:ok, _} = P.validate(Map.put(r, "observation", o), host(c)) + end + end + + test "canonical exact byte budget and maximum counter are independent" do + for text <- [ + String.duplicate("x", 1_048_576 - 8), + String.duplicate("Γ©", div(1_048_576 - 8, 2)) + ] do + assert {:error, :invalid_message} = P.validate(%{"x" => text}, context()) + assert {:error, :transport_refused} = P.validate(%{"x" => text <> "x"}, context()) + end + + c = context() + m = Map.put(command(), "intent_version", 9_223_372_036_854_775_807) + + assert {:ok, _} = + P.validate(m, %{ + c + | snapshot: %{c.snapshot | command: m, current_intent: 9_223_372_036_854_775_807} + }) + end + + test "normalized result budget includes optional default overhead" do + o = Map.put(observation(), "provider_instance_id", "") + m = Map.put(result(), "observation", o) + + normalized = + Map.put(m, "observation", Map.merge(o, %{"effective_config" => nil, "deleted" => false})) + + fill = 1_048_576 - byte_size(Jason.encode!(normalized)) + c = host(context()) + exact = put_in(m, ["observation", "provider_instance_id"], String.duplicate("x", fill)) + assert {:ok, g} = P.decode(Jason.encode!(exact), c) + assert byte_size(g.canonical) == 1_048_576 + over = put_in(m, ["observation", "provider_instance_id"], String.duplicate("x", fill + 1)) + raw = Jason.encode!(over) + assert byte_size(raw) <= 1_048_576 + assert {:error, :transport_refused} = P.decode(raw, c) + assert {:error, :transport_refused} = P.validate(over, c) + end + + test "wire observation effective config is already normalized" do + for cfg <- [ + Map.delete(config(), "submodules"), + %{ + config() + | "submodules" => [ + %{"path" => "z", "commit" => String.duplicate("a", 40)}, + %{"path" => "a", "commit" => String.duplicate("b", 40)} + ] + } + ] do + m = Map.put(result(), "observation", Map.put(observation(), "effective_config", cfg)) + assert {:error, :invalid_message} = P.validate(m, host(context())) + end + end + + test "workspace scope matches separately authorized expectation" do + m = command() |> Map.put("workspace_id", id(9)) |> put_in(["request", "workspace_id"], id(9)) + assert {:error, :unauthorized_scope} = P.validate(m, context()) + end +end diff --git a/apps/control_plane/test/workharbor/storage_binding_test.exs b/apps/control_plane/test/workharbor/storage_binding_test.exs new file mode 100644 index 00000000..c2de2753 --- /dev/null +++ b/apps/control_plane/test/workharbor/storage_binding_test.exs @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.StorageBindingTest do + use ExUnit.Case, async: false + alias WorkHarbor.{Foundation, Repo} + alias WorkHarbor.FoundationDatabase, as: DB + + setup do + path = Path.join(System.tmp_dir!(), "whr-storage-#{Ecto.UUID.generate()}.db") + repo = DB.open!(path) + {owner, host} = DB.fixture!() + {:ok, {:accepted, op}} = Foundation.accept(owner, "create", DB.request(host), "cli") + [[storage]] = DB.sql("SELECT id FROM persistent_storage").rows + + on_exit(fn -> + if Process.alive?(repo), do: GenServer.stop(repo) + for suffix <- ["", "-wal", "-shm"], do: File.rm(path <> suffix) + end) + + %{repo: repo, owner: owner, host: host, op: op, storage: storage} + end + + defp storage_binding(c, op \\ nil), + do: + Foundation.storage_binding( + c.owner, + c.host, + (op || c.op).id, + (op || c.op).intent_version, + c.storage + ) + + defp observe(c, handle), + do: + Foundation.observe_storage(c.owner, c.host, c.op.id, c.op.intent_version, c.storage, handle) + + test "binding carries original create descriptor and reservation", c do + assert {:ok, b} = storage_binding(c) + + assert b == %{ + storage_id: c.storage, + owner_id: c.owner, + host_id: c.host, + workspace_id: c.op.workspace_id, + reservation_operation_id: c.op.id, + initial_config: DB.config(), + provider_handle: nil + } + end + + test "first handle requires executing current scope and repeats immutably", c do + assert {:error, :invalid_transition} = observe(c, "host-private-handle") + assert {:ok, {:claimed, _}} = Foundation.claim(c.owner, c.host, c.op.id, 1) + assert {:ok, b} = observe(c, "host-private-handle") + assert b.provider_handle == "host-private-handle" + assert {:ok, ^b} = observe(c, "host-private-handle") + assert {:error, :storage_mismatch} = observe(c, "different-handle") + assert {:error, :invalid_request} = observe(c, "") + assert {:ok, ^b} = storage_binding(c) + end + + test "foreign operation storage host owner and stale intent refuse", c do + for {owner, host, op, intent, storage} <- [ + {Ecto.UUID.generate(), c.host, c.op.id, 1, c.storage}, + {c.owner, Ecto.UUID.generate(), c.op.id, 1, c.storage}, + {c.owner, c.host, Ecto.UUID.generate(), 1, c.storage}, + {c.owner, c.host, c.op.id, 1, Ecto.UUID.generate()}, + {c.owner, c.host, c.op.id, 0, c.storage} + ] do + assert {:error, code} = Foundation.storage_binding(owner, host, op, intent, storage) + + assert {:error, other} = + Foundation.observe_storage(owner, host, op, intent, storage, "handle") + + assert code in [:not_found, :storage_unavailable, :stale_intent] + assert other in [:not_found, :invalid_transition, :stale_intent] + end + + assert [[nil]] = DB.sql("SELECT provider_handle FROM persistent_storage").rows + end + + test "later approved revision retains original seed and handle", c do + {:ok, {:claimed, _}} = Foundation.claim(c.owner, c.host, c.op.id, 1) + assert {:ok, _} = observe(c, "retained") + DB.finish!(c.owner, c.host, %{c.op | status: "executing"}) + + request = %{ + DB.request(c.host, c.op.workspace_id, "rebuild") + | "parameters" => %{"config" => DB.config(String.duplicate("b", 40))} + } + + {:ok, {:accepted, next}} = Foundation.accept(c.owner, "rebuild", request, "cli") + assert next.execution_config_snapshot != DB.config() + assert {:ok, b} = storage_binding(c, next) + assert b.initial_config == DB.config() + assert b.reservation_operation_id == c.op.id + assert b.provider_handle == "retained" + assert {:error, :stale_intent} = storage_binding(c) + end + + test "independent competing handles have one winner and never replace", c do + {:ok, {:claimed, _}} = Foundation.claim(c.owner, c.host, c.op.id, 1) + + results = + for handle <- ["one", "two"] do + Task.async(fn -> + Repo.put_dynamic_repo(c.repo) + observe(c, handle) + end) + end + |> Enum.map(&Task.await(&1, 5000)) + + assert Enum.count(results, &match?({:ok, _}, &1)) == 1 + assert Enum.count(results, &(&1 == {:error, :storage_mismatch})) == 1 + end +end diff --git a/apps/control_plane/test/workharbor/strict_json_test.exs b/apps/control_plane/test/workharbor/strict_json_test.exs new file mode 100644 index 00000000..177a80e9 --- /dev/null +++ b/apps/control_plane/test/workharbor/strict_json_test.exs @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarbor.StrictJSONTest do + use ExUnit.Case, async: true + alias WorkHarbor.StrictJSON, as: J + + test "decoded duplicate keys reject at every depth before maps" do + for raw <- [~S({"a":1,"\u0061":2}), ~S({"x":[{"a":1,"a":2}]}), ~S({"\ud800\udc00":1,"π":2})], + do: assert({:error, :invalid_json} == J.decode(raw)) + + assert {:ok, %{"Γ©" => 1, "eΜ" => 2}} = J.decode(~S({"Γ©":1,"eΜ":2})) + end + + test "strict Unicode floats syntax and huge numeric forms reject safely" do + for raw <- [ + <<255>>, + <<239, 187, 191, 123, 125>>, + ~S("\ud800"), + ~S("\udc00"), + ~S("\udc00\ud800"), + "1.0", + "1e0", + "NaN", + "Infinity", + "{}{}", + "{", + ~S("\q"), + "1." <> String.duplicate("1", 100_000), + "1e" <> String.duplicate("1", 100_000) + ], + do: assert({:error, :invalid_json} == J.decode(raw)) + + assert {:ok, "π"} = J.decode(~S("\ud800\udc00")) + end + + test "exact byte budget rejects oversize before invalid UTF8" do + assert {:ok, %{}} = J.decode("{}" <> String.duplicate(" ", 1_048_574)) + assert {:error, :input_limit_exceeded} = J.decode(<<255>> <> String.duplicate(" ", 1_048_576)) + assert {:error, :invalid_json} = J.decode(nil) + end + + test "integer and container boundaries retain minus sign semantics" do + for sign <- ["", "-"] do + raw = sign <> String.duplicate("1", 4300) + assert {:ok, n} = J.decode(raw) + assert Integer.to_string(n) == raw + assert {:error, :invalid_json} = J.decode(raw <> "1") + end + + for {o, c} <- [{"[", "]"}, {"{\"x\":", "}"}] do + assert {:ok, _} = J.decode(String.duplicate(o, 128) <> "0" <> String.duplicate(c, 128)) + + assert {:error, :invalid_json} = + J.decode(String.duplicate(o, 129) <> "0" <> String.duplicate(c, 129)) + end + + assert {:ok, 0} = J.decode("-0") + assert {:ok, _} = J.decode(~S({"x":"[12345{\\\"}"})) + end + + test "native values retain frozen scalar ordering and API object shape" do + assert {:ok, value} = J.decode(~S({"π":2,"ξ":1})) + assert {:ok, bytes} = WorkHarbor.Foundation.Canonical.bytes(value) + assert Base.encode16(bytes, case: :lower) == "7b22ee8080223a312c22f0908080223a327d" + + for raw <- ["", "[]", "1", "null"], + do: assert({:error, :invalid_json} == J.decode_object(raw)) + end + + test "fixed forms reject decoded aliases malformed escapes unknown fields and byte overflow" do + alias WorkHarborWeb.LoginForm, as: F + fields = ["owner_id", "owner_secret", "_csrf_token"] + + assert {:ok, %{"owner_id" => "x", "owner_secret" => "a b", "_csrf_token" => "x"}} = + F.decode("owner_id=x&owner_secret=a+b&_csrf_token=x", fields) + + for raw <- [ + "owner_id=x&%6fwner_id=y", + "owner_id=%GG", + "owner_id=%FF", + "owner_id=x&owner_secret[a]=b", + "owner_id=x&&owner_secret=y", + "owner_id=x&x=y", + "owner_id=x&owner_secret=y&_csrf_token=x&x=y" + ], + do: assert({:error, :invalid_form} == F.decode(raw, fields)) + + assert {:error, :input_limit_exceeded} = F.decode(String.duplicate("x", 4097), fields) + end + + test "all seventy four frozen raw byte parse vectors match canonical bytes and rejection" do + file = Path.expand("../../../../contracts/migration/vectors.json", __DIR__) + + cases = + File.read!(file) + |> Jason.decode!() + |> Map.fetch!("cases") + |> Enum.filter(&(&1["operation"] == "parse-json" and &1["input"]["kind"] == "bytes")) + + assert length(cases) == 74 + + for c <- cases do + raw = expand(c["input"]["data"]) + expected = c["product_expected"] + + case {J.decode(raw), expected["status"]} do + {{:ok, value}, "ok"} -> + assert {:ok, bytes} = WorkHarbor.Foundation.Canonical.bytes(value) + assert bytes == expand(expected["canonical"]), c["id"] + + assert Base.encode16(:crypto.hash(:sha256, bytes), case: :lower) == expected["digest"], + c["id"] + + {{:error, code}, "reject"} -> + assert String.replace(Atom.to_string(code), "_", "-") == expected["code"], c["id"] + + {result, _} -> + flunk("vector mismatch #{c["id"]}: #{inspect(result, limit: 1)}") + end + end + end + + defp expand(%{"encoding" => "hex", "hex" => hex} = data) do + bytes = Base.decode16!(hex, case: :mixed) + assert byte_size(bytes) == data["length"] + assert Base.encode16(:crypto.hash(:sha256, bytes), case: :lower) == data["sha256"] + bytes + end + + defp expand(%{"encoding" => "repeat"} = data) do + bytes = + Base.decode16!(data["prefix_hex"], case: :mixed) <> + :binary.copy(Base.decode16!(data["unit_hex"], case: :mixed), data["count"]) <> + Base.decode16!(data["suffix_hex"], case: :mixed) + + assert byte_size(bytes) == data["length"] + assert Base.encode16(:crypto.hash(:sha256, bytes), case: :lower) == data["sha256"] + bytes + end +end diff --git a/apps/control_plane/test/workharbor_web/agent_ingress_test.exs b/apps/control_plane/test/workharbor_web/agent_ingress_test.exs new file mode 100644 index 00000000..494a6eb3 --- /dev/null +++ b/apps/control_plane/test/workharbor_web/agent_ingress_test.exs @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.AgentIngressTest do + use ExUnit.Case, async: false + import Plug.Conn + import Plug.Test + alias WorkHarborWeb.AgentIngress + + defp request(method, path, body \\ "") do + c = conn(method, "https://localhost" <> path, body) + + %{c | req_headers: [{"host", "localhost"} | c.req_headers]} + |> put_req_header("content-type", "application/json") + end + + test "agent namespace is admitted before browser session and rejects mixed credentials" do + for {header, value} <- [ + {"authorization", "Bearer SECRET_SENTINEL"}, + {"cookie", "SECRET_SENTINEL"}, + {"origin", "https://localhost"}, + {"sec-fetch-site", "same-origin"}, + {"x-workharbor-context", "owner"} + ] do + result = + request(:post, "/agent/v1/enroll", "{}") + |> put_req_header(header, value) + |> AgentIngress.call([]) + + assert result.status == 403 + refute Map.has_key?(result.private, :plug_session) + refute result.resp_body =~ "SECRET_SENTINEL" + end + end + + test "agent TLS authority exact path and query are checked before bodies" do + for c <- [ + request(:post, "/agent/v1/enroll?code=SECRET_SENTINEL"), + request(:post, "/agent/v1/enroll/"), + %{request(:post, "/agent/v1/enroll") | scheme: :http}, + %{request(:post, "/agent/v1/enroll") | req_headers: [{"host", "elsewhere"}]} + ] do + result = AgentIngress.call(c, []) + assert result.status in [400, 403] + refute result.resp_body =~ "SECRET_SENTINEL" + end + end + + test "assigned JSON and owner principal never substitute for actual peer certificate" do + for path <- ["/agent/v1/identity/renew", "/agent/v1/identity/activate", "/agent/v1/connect"] do + c = + request(if(path == "/agent/v1/connect", do: :get, else: :post), path, "{}") + |> assign(:peer, %{authenticated: true}) + |> assign(:principal, %{owner_id: Ecto.UUID.generate()}) + + result = AgentIngress.call(c, []) + assert result.status == 401 + assert Jason.decode!(result.resp_body) == %{"error" => %{"code" => "unauthorized_host"}} + end + end + + test "enrollment body is closed strict and bounded with fixed redacted errors" do + for raw <- [ + ~s({"schema_version":1,"schema_version":1}), + ~s({"schema_version":1.0,"code":"SECRET_SENTINEL","csr":""}), + ~s({"schema_version":1,"code":"SECRET_SENTINEL","csr":"","extra":true}), + String.duplicate(" ", 16385) + ] do + result = AgentIngress.call(request(:post, "/agent/v1/enroll", raw), []) + assert result.status == 400 + refute result.resp_body =~ "SECRET_SENTINEL" + end + end + + test "failed agent body validation preserves the consumed adapter state" do + result = + AgentIngress.call( + request(:post, "/agent/v1/enroll", ~s({"schema_version":1,"extra":"SECRET_SENTINEL"})), + [] + ) + + assert elem(result.adapter, 1).req_body == "" + end +end diff --git a/apps/control_plane/test/workharbor_web/agent_log_filter_test.exs b/apps/control_plane/test/workharbor_web/agent_log_filter_test.exs new file mode 100644 index 00000000..c8530205 --- /dev/null +++ b/apps/control_plane/test/workharbor_web/agent_log_filter_test.exs @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.AgentLogFilterTest do + use ExUnit.Case, async: false + alias WorkHarborWeb.{AgentLogFilter, AgentSocket} + + test "agent crash events rebuild all report and metadata to fixed safe values" do + for label <- [{:gen_server, :terminate}, {:proc_lib, :crash}] do + event = %{ + level: :error, + msg: + {:report, + %{ + label: label, + state: "SECRET_SENTINEL", + last_message: "SECRET_SENTINEL", + log: "SECRET_SENTINEL", + reason: "SECRET_SENTINEL", + client_info: "SECRET_SENTINEL", + extra: "SECRET_SENTINEL", + report: ["SECRET_SENTINEL"] + }}, + meta: %{ + workharbor_agent_transport: true, + pid: self(), + time: 123, + domain: [:otp], + report_cb: "SECRET_SENTINEL", + error_logger: "SECRET_SENTINEL", + crash_reason: "SECRET_SENTINEL", + extra: "SECRET_SENTINEL" + }, + extra: "SECRET_SENTINEL" + } + + assert AgentLogFilter.filter(event, []) == %{ + level: :error, + msg: {:string, "WorkHarbor agent transport refused"}, + meta: %{pid: self(), time: 123, domain: [:workharbor, :agent_transport]} + } + end + + fallback = %{ + level: :error, + msg: + {:report, + %{ + label: {:gen_server, :terminate}, + state: + {:socket, + %{ + connection: %Bandit.WebSocket.Connection{ + websock: AgentSocket, + websock_state: "SECRET_SENTINEL" + } + }} + }}, + meta: %{pid: "SECRET_SENTINEL", time: "SECRET_SENTINEL", report_cb: "SECRET_SENTINEL"} + } + + assert AgentLogFilter.filter(fallback, []) == %{ + level: :error, + msg: {:string, "WorkHarbor agent transport refused"}, + meta: %{domain: [:workharbor, :agent_transport]} + } + end + + test "filter is total and leaves unrelated events unchanged" do + for event <- [ + nil, + [], + %{}, + %{ + msg: {:report, %{label: {:gen_server, :terminate}, state: :other}}, + meta: %{pid: self()} + }, + %{msg: {:report, %{label: :unrelated}}, meta: %{workharbor_agent_transport: true}}, + %{msg: {:report, %{label: {:proc_lib, :crash}}}, meta: :invalid} + ] do + assert AgentLogFilter.filter(event, []) == :ignore + end + end + + test "owned filter installation is idempotent and refuses a conflicting owner" do + id = :workharbor_agent_transport + previous = List.keyfind(:logger.get_primary_config().filters, id, 0) + :logger.remove_primary_filter(id) + + on_exit(fn -> + :logger.remove_primary_filter(id) + if previous, do: :logger.add_primary_filter(id, elem(previous, 1)) + end) + + assert :ok = AgentLogFilter.install() + assert :ok = AgentLogFilter.install() + :logger.remove_primary_filter(id) + conflicting = {fn event, _ -> event end, []} + assert :ok = :logger.add_primary_filter(id, conflicting) + assert {:error, :unsafe_agent_logging} = AgentLogFilter.install() + assert {^id, ^conflicting} = List.keyfind(:logger.get_primary_config().filters, id, 0) + end +end diff --git a/apps/control_plane/test/workharbor_web/authentication_test.exs b/apps/control_plane/test/workharbor_web/authentication_test.exs new file mode 100644 index 00000000..660dd01a --- /dev/null +++ b/apps/control_plane/test/workharbor_web/authentication_test.exs @@ -0,0 +1,964 @@ +# SPDX-License-Identifier: EUPL-1.2 + +defmodule WorkHarborWeb.AuthenticationTest do + use ExUnit.Case, async: false + import Plug.Conn + alias WorkHarborWeb.Endpoint + alias WorkHarbor.FoundationDatabase, as: DB + + setup do + path = Path.join(System.tmp_dir!(), "workharbor-http-auth-#{Ecto.UUID.generate()}.db") + pid = WorkHarbor.AuthDatabase.open!(path) + {owner, secret} = WorkHarbor.AuthDatabase.fixture!() + Application.put_env(:workharbor, :clock, fn -> 2_000_000_000 end) + + on_exit(fn -> + Application.delete_env(:workharbor, :clock) + if Process.alive?(pid), do: GenServer.stop(pid) + for s <- ["", "-wal", "-shm"], do: File.rm(path <> s) + end) + + %{owner: owner, secret: secret} + end + + defp req(method, path, body \\ "", headers \\ []) do + conn = + Plug.Test.conn(method, "https://localhost" <> path, body) + + conn = %{conn | req_headers: headers ++ [{"host", "localhost"} | conn.req_headers]} + Endpoint.call(conn, Endpoint.init([])) + end + + defp cookie(conn, name), do: conn.resp_cookies[name].value + defp jar(pairs), do: Enum.map_join(pairs, "; ", fn {k, v} -> k <> "=" <> v end) + + defp csrf(conn), + do: Regex.run(~r/name="_csrf_token" value="([^"]+)"/, conn.resp_body) |> Enum.at(1) + + defp primary(c, kind) do + page = req(:get, "/login/" <> kind) + + form = + URI.encode_query(%{ + "owner_id" => c.owner, + "owner_secret" => c.secret, + "_csrf_token" => csrf(page) + }) + + req(:post, "/login/" <> kind, form, [ + {"content-type", "application/x-www-form-urlencoded"}, + {"origin", "https://localhost"}, + {"cookie", jar([{"__Host-workharbor_login", cookie(page, "__Host-workharbor_login")}])} + ]) + end + + test "actual endpoint rejects plaintext wrong host and foreign origin before login", c do + body = Jason.encode!(%{schema_version: 1, owner_id: c.owner, owner_secret: c.secret}) + + http = + Plug.Test.conn(:post, "http://localhost/api/v1/auth/login", body) + |> put_req_header("content-type", "application/json") + + assert Endpoint.call(http, Endpoint.init([])).status == 403 + + for h <- [ + {"host", "evil.test"}, + {"host", "localhost.evil"}, + {"origin", "https://evil.test"}, + {"origin", "null"}, + {"origin", "https://localhost/"} + ], + do: + assert( + req(:post, "/api/v1/auth/login", body, [{"content-type", "application/json"}, h]).status == + 403 + ) + + assert DB.count("owner_credentials") == 0 + end + + test "strict API login rejects alternate parsers duplicate fields and mixed credentials", c do + body = Jason.encode!(%{schema_version: 1, owner_id: c.owner, owner_secret: c.secret}) + good = req(:post, "/api/v1/auth/login", body, [{"content-type", "application/json"}]) + assert good.status == 200 + assert %{"token" => token} = Jason.decode!(good.resp_body) + + for h <- [ + {"origin", "https://localhost"}, + {"sec-fetch-site", "same-origin"}, + {"x-workharbor-context", "owner"}, + {"authorization", "Bearer " <> token} + ], + do: + assert( + req(:post, "/api/v1/auth/login", body, [{"content-type", "application/json"}, h]).status in [ + 400, + 403 + ] + ) + + for raw <- [ + "{}", + ~s({"owner_id":"a","owner_id":"b"}), + "[]", + "", + String.duplicate(" ", 1_048_577) + ], + do: + assert( + req(:post, "/api/v1/auth/login", raw, [{"content-type", "application/json"}]).status in [ + 400, + 413 + ] + ) + + assert req(:post, "/api/v1/auth/login", body, [ + {"content-type", "application/x-www-form-urlencoded"} + ]).status == 415 + end + + test "public cross site login creates preauth only and cookies deny scripts", c do + owner = primary(c, "owner") + dashboard = primary(c, "dashboard") + assert owner.status == 303 and dashboard.status == 303 + page = req(:get, "/login/owner", "", [{"sec-fetch-site", "cross-site"}]) + assert page.status == 200 + assert Map.keys(page.resp_cookies) == ["__Host-workharbor_login"] + refute page.resp_body =~ c.secret + refute page.resp_body =~ c.owner + refute page.resp_body =~ "
{@title}
{msg}
+ <.icon name="hero-exclamation-circle" class="size-5" /> + {render_slot(@inner_block)} +
+ {render_slot(@subtitle)} +
+ Peace of mind from prototype to production. +
+ Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. +