@emilia-protocol/gate 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/action-control-manifest.js +9 -3
- package/breakglass.js +349 -0
- package/deploy/helm/README.md +110 -0
- package/deploy/helm/emilia-gate/Chart.yaml +39 -0
- package/deploy/helm/emilia-gate/templates/NOTES.txt +48 -0
- package/deploy/helm/emilia-gate/templates/configmap.yaml +27 -0
- package/deploy/helm/emilia-gate/templates/deployment.yaml +134 -0
- package/deploy/helm/emilia-gate/templates/service.yaml +28 -0
- package/deploy/helm/emilia-gate/templates/servicemonitor.yaml +41 -0
- package/deploy/helm/emilia-gate/values.yaml +169 -0
- package/deploy/terraform/README.md +138 -0
- package/deploy/terraform/main.tf +256 -0
- package/deploy/terraform/outputs.tf +33 -0
- package/deploy/terraform/variables.tf +205 -0
- package/deploy/terraform/versions.tf +18 -0
- package/enterprise.js +174 -0
- package/index.js +125 -10
- package/metering.js +227 -0
- package/metrics.js +232 -0
- package/package.json +39 -4
- package/reliance-packet.js +80 -2
- package/reports/art14.js +0 -0
- package/reports/auditor-workpaper.js +538 -0
- package/reports/reperform.js +365 -0
- package/reports/underwriter.js +340 -0
- package/roster.js +265 -0
- package/siem.js +236 -0
- package/store-postgres.js +129 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
#
|
|
3
|
+
# EMILIA Gate — reference Terraform module (EP-GATE-TF-v1).
|
|
4
|
+
#
|
|
5
|
+
# The Terraform sibling of the Helm chart (deploy/helm, EP-GATE-HELM-v1) for
|
|
6
|
+
# BYOC installs: the deployer's cluster, the deployer's keys. Same container
|
|
7
|
+
# contract as the chart — EP_GATE_* env, the action-risk manifest mounted at
|
|
8
|
+
# /etc/emilia-gate/action-risk-manifest.json, pinned issuer keys arriving as
|
|
9
|
+
# EP_GATE_ISSUER_KEYS from a Secret the DEPLOYER created. Three resources,
|
|
10
|
+
# nothing hidden:
|
|
11
|
+
#
|
|
12
|
+
# kubernetes_config_map_v1.manifest — the action-risk manifest (policy);
|
|
13
|
+
# kubernetes_deployment_v1.gate — the gate pods, hardened defaults
|
|
14
|
+
# (non-root, read-only rootfs, no privilege escalation, all capabilities
|
|
15
|
+
# dropped, no service-account token);
|
|
16
|
+
# kubernetes_service_v1.gate — cluster-internal Service in front of
|
|
17
|
+
# the pods (ClusterIP by default).
|
|
18
|
+
#
|
|
19
|
+
# Fail-closed wiring:
|
|
20
|
+
# - the issuer-keys secretKeyRef is NOT optional: if the deployer has not
|
|
21
|
+
# provisioned pinned issuer keys, the pods do not start — a gate without
|
|
22
|
+
# pinned issuers must never come up permissive;
|
|
23
|
+
# - EP_GATE_EVIDENCE_STRICT defaults to true: the gate refuses to authorize
|
|
24
|
+
# an action it cannot durably account for;
|
|
25
|
+
# - the manifest is validated as JSON at plan time (see variables.tf) and a
|
|
26
|
+
# sha256 of it is annotated onto the pod template, so every manifest change
|
|
27
|
+
# rolls the pods — a stale policy can't keep serving silently.
|
|
28
|
+
|
|
29
|
+
locals {
|
|
30
|
+
module_version = "EP-GATE-TF-v1"
|
|
31
|
+
|
|
32
|
+
labels = merge({
|
|
33
|
+
"app.kubernetes.io/name" = var.name
|
|
34
|
+
"app.kubernetes.io/component" = "trusted-action-firewall"
|
|
35
|
+
"app.kubernetes.io/part-of" = "emilia-protocol"
|
|
36
|
+
"emiliaprotocol.ai/module-contract" = local.module_version
|
|
37
|
+
"emiliaprotocol.ai/maturity" = "experimental"
|
|
38
|
+
}, var.extra_labels)
|
|
39
|
+
|
|
40
|
+
selector_labels = {
|
|
41
|
+
"app.kubernetes.io/name" = var.name
|
|
42
|
+
"app.kubernetes.io/component" = "trusted-action-firewall"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Same paths as the Helm chart's container contract.
|
|
46
|
+
manifest_mount_dir = "/etc/emilia-gate"
|
|
47
|
+
manifest_file = "action-risk-manifest.json"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# The policy the gate enforces. Plain ConfigMap: the manifest is deny-by-default
|
|
51
|
+
# POLICY, not a secret — auditors should be able to read it in-cluster.
|
|
52
|
+
resource "kubernetes_config_map_v1" "manifest" {
|
|
53
|
+
metadata {
|
|
54
|
+
name = "${var.name}-manifest"
|
|
55
|
+
namespace = var.namespace
|
|
56
|
+
labels = local.labels
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
data = {
|
|
60
|
+
(local.manifest_file) = var.manifest_json
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
resource "kubernetes_deployment_v1" "gate" {
|
|
65
|
+
metadata {
|
|
66
|
+
name = var.name
|
|
67
|
+
namespace = var.namespace
|
|
68
|
+
labels = local.labels
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
spec {
|
|
72
|
+
replicas = var.replicas
|
|
73
|
+
|
|
74
|
+
selector {
|
|
75
|
+
match_labels = local.selector_labels
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
template {
|
|
79
|
+
metadata {
|
|
80
|
+
labels = local.labels
|
|
81
|
+
annotations = {
|
|
82
|
+
# Roll the pods whenever the policy changes.
|
|
83
|
+
"emiliaprotocol.ai/manifest-sha256" = sha256(var.manifest_json)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
spec {
|
|
88
|
+
# The gate does not call the Kubernetes API; no token, no ambient authority.
|
|
89
|
+
automount_service_account_token = false
|
|
90
|
+
|
|
91
|
+
security_context {
|
|
92
|
+
run_as_non_root = true
|
|
93
|
+
run_as_user = var.run_as_user
|
|
94
|
+
run_as_group = var.run_as_user
|
|
95
|
+
fs_group = var.run_as_user
|
|
96
|
+
|
|
97
|
+
seccomp_profile {
|
|
98
|
+
type = "RuntimeDefault"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
container {
|
|
103
|
+
name = "gate"
|
|
104
|
+
image = var.image
|
|
105
|
+
image_pull_policy = var.image_pull_policy
|
|
106
|
+
|
|
107
|
+
port {
|
|
108
|
+
name = "http"
|
|
109
|
+
container_port = var.port
|
|
110
|
+
protocol = "TCP"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
env {
|
|
114
|
+
name = "NODE_ENV"
|
|
115
|
+
value = "production"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
env {
|
|
119
|
+
name = "EP_GATE_PORT"
|
|
120
|
+
value = tostring(var.port)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
env {
|
|
124
|
+
name = "EP_GATE_LOG_LEVEL"
|
|
125
|
+
value = var.log_level
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# Strict evidence log: never authorize an action the gate cannot
|
|
129
|
+
# durably account for.
|
|
130
|
+
env {
|
|
131
|
+
name = "EP_GATE_EVIDENCE_STRICT"
|
|
132
|
+
value = tostring(var.evidence_strict)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
env {
|
|
136
|
+
name = "EP_GATE_MANIFEST_PATH"
|
|
137
|
+
value = "${local.manifest_mount_dir}/${local.manifest_file}"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
env {
|
|
141
|
+
name = "EP_GATE_METRICS_ENABLED"
|
|
142
|
+
value = tostring(var.metrics_enabled)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# Pinned issuer keys — REFERENCED from an existing Secret, never
|
|
146
|
+
# passed through Terraform. optional=false is the fail-closed
|
|
147
|
+
# switch: no pinned issuers, no pods.
|
|
148
|
+
env {
|
|
149
|
+
name = "EP_GATE_ISSUER_KEYS"
|
|
150
|
+
value_from {
|
|
151
|
+
secret_key_ref {
|
|
152
|
+
name = var.issuer_keys_secret_name
|
|
153
|
+
key = var.issuer_keys_secret_key
|
|
154
|
+
optional = false
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
dynamic "env" {
|
|
160
|
+
for_each = var.extra_env
|
|
161
|
+
content {
|
|
162
|
+
name = env.key
|
|
163
|
+
value = env.value
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
volume_mount {
|
|
168
|
+
name = "action-risk-manifest"
|
|
169
|
+
mount_path = local.manifest_mount_dir
|
|
170
|
+
read_only = true
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# Writable scratch for the runtime; rootfs stays read-only.
|
|
174
|
+
volume_mount {
|
|
175
|
+
name = "tmp"
|
|
176
|
+
mount_path = "/tmp"
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
resources {
|
|
180
|
+
requests = var.resources.requests
|
|
181
|
+
limits = var.resources.limits
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
dynamic "liveness_probe" {
|
|
185
|
+
for_each = var.liveness_path == null ? [] : [var.liveness_path]
|
|
186
|
+
content {
|
|
187
|
+
http_get {
|
|
188
|
+
path = liveness_probe.value
|
|
189
|
+
port = "http"
|
|
190
|
+
}
|
|
191
|
+
initial_delay_seconds = 5
|
|
192
|
+
period_seconds = 10
|
|
193
|
+
timeout_seconds = 2
|
|
194
|
+
failure_threshold = 3
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
dynamic "readiness_probe" {
|
|
199
|
+
for_each = var.readiness_path == null ? [] : [var.readiness_path]
|
|
200
|
+
content {
|
|
201
|
+
http_get {
|
|
202
|
+
path = readiness_probe.value
|
|
203
|
+
port = "http"
|
|
204
|
+
}
|
|
205
|
+
initial_delay_seconds = 5
|
|
206
|
+
period_seconds = 10
|
|
207
|
+
timeout_seconds = 2
|
|
208
|
+
failure_threshold = 3
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
security_context {
|
|
213
|
+
allow_privilege_escalation = false
|
|
214
|
+
read_only_root_filesystem = var.read_only_root_filesystem
|
|
215
|
+
|
|
216
|
+
capabilities {
|
|
217
|
+
drop = ["ALL"]
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
volume {
|
|
223
|
+
name = "action-risk-manifest"
|
|
224
|
+
config_map {
|
|
225
|
+
name = kubernetes_config_map_v1.manifest.metadata[0].name
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
volume {
|
|
230
|
+
name = "tmp"
|
|
231
|
+
empty_dir {}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
resource "kubernetes_service_v1" "gate" {
|
|
239
|
+
metadata {
|
|
240
|
+
name = var.name
|
|
241
|
+
namespace = var.namespace
|
|
242
|
+
labels = local.labels
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
spec {
|
|
246
|
+
type = var.service_type
|
|
247
|
+
selector = local.selector_labels
|
|
248
|
+
|
|
249
|
+
port {
|
|
250
|
+
name = "http"
|
|
251
|
+
port = var.service_port
|
|
252
|
+
target_port = "http"
|
|
253
|
+
protocol = "TCP"
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
#
|
|
3
|
+
# EMILIA Gate — reference Terraform module (EP-GATE-TF-v1) — outputs.
|
|
4
|
+
|
|
5
|
+
output "module_version" {
|
|
6
|
+
description = "Versioned identifier of this reference module."
|
|
7
|
+
value = "EP-GATE-TF-v1"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
output "service_name" {
|
|
11
|
+
description = "Name of the Kubernetes Service fronting the gate."
|
|
12
|
+
value = kubernetes_service_v1.gate.metadata[0].name
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
output "namespace" {
|
|
16
|
+
description = "Namespace the gate is deployed into."
|
|
17
|
+
value = kubernetes_service_v1.gate.metadata[0].namespace
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
output "service_endpoint" {
|
|
21
|
+
description = "In-cluster HTTP endpoint of the gate (cluster-DNS form). Point guarded callers at this."
|
|
22
|
+
value = "http://${kubernetes_service_v1.gate.metadata[0].name}.${kubernetes_service_v1.gate.metadata[0].namespace}.svc.cluster.local:${var.service_port}"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
output "deployment_name" {
|
|
26
|
+
description = "Name of the gate Deployment."
|
|
27
|
+
value = kubernetes_deployment_v1.gate.metadata[0].name
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
output "manifest_config_map_name" {
|
|
31
|
+
description = "Name of the ConfigMap carrying the action-risk manifest."
|
|
32
|
+
value = kubernetes_config_map_v1.manifest.metadata[0].name
|
|
33
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
#
|
|
3
|
+
# EMILIA Gate — reference Terraform module (EP-GATE-TF-v1) — inputs.
|
|
4
|
+
#
|
|
5
|
+
# KEY CUSTODY RULE (non-negotiable): issuer public keys are consumed from an
|
|
6
|
+
# EXISTING Kubernetes Secret that the deployer creates and controls. This
|
|
7
|
+
# module takes the secret's NAME only. It never accepts key material inline,
|
|
8
|
+
# never creates a Secret, and never reads secret data — so no key bytes ever
|
|
9
|
+
# enter Terraform state, plan output, or version control via this module.
|
|
10
|
+
|
|
11
|
+
variable "name" {
|
|
12
|
+
description = "Base name for all resources (Deployment, Service, ConfigMap prefix)."
|
|
13
|
+
type = string
|
|
14
|
+
default = "emilia-gate"
|
|
15
|
+
|
|
16
|
+
validation {
|
|
17
|
+
condition = can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", var.name)) && length(var.name) <= 53
|
|
18
|
+
error_message = "name must be a DNS-1123 label (lowercase alphanumerics and '-', max 53 chars to leave room for suffixes)."
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
variable "namespace" {
|
|
23
|
+
description = "Kubernetes namespace to deploy into. Must already exist — this module does not create namespaces."
|
|
24
|
+
type = string
|
|
25
|
+
default = "default"
|
|
26
|
+
|
|
27
|
+
validation {
|
|
28
|
+
condition = can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", var.namespace))
|
|
29
|
+
error_message = "namespace must be a DNS-1123 label."
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
variable "image" {
|
|
34
|
+
description = "Container image for the gate (registry/repo:tag or @digest). BYOC: build and push your own image; pin a digest or exact tag for production — never a floating tag."
|
|
35
|
+
type = string
|
|
36
|
+
|
|
37
|
+
validation {
|
|
38
|
+
condition = length(trimspace(var.image)) > 0
|
|
39
|
+
error_message = "image is required."
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
variable "image_pull_policy" {
|
|
44
|
+
description = "Kubernetes imagePullPolicy for the gate container."
|
|
45
|
+
type = string
|
|
46
|
+
default = "IfNotPresent"
|
|
47
|
+
|
|
48
|
+
validation {
|
|
49
|
+
condition = contains(["Always", "IfNotPresent", "Never"], var.image_pull_policy)
|
|
50
|
+
error_message = "image_pull_policy must be one of Always, IfNotPresent, Never."
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
variable "replicas" {
|
|
55
|
+
description = "Number of gate replicas. Replay defense across >1 replica requires a shared consumption store (see README) — the module deploys the pods either way, but fleet-safety is the deployer's configuration responsibility."
|
|
56
|
+
type = number
|
|
57
|
+
default = 2
|
|
58
|
+
|
|
59
|
+
validation {
|
|
60
|
+
condition = var.replicas >= 1 && floor(var.replicas) == var.replicas
|
|
61
|
+
error_message = "replicas must be a whole number >= 1."
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
variable "manifest_json" {
|
|
66
|
+
description = "The action-risk manifest (EP-ACTION-RISK-MANIFEST) as a JSON string — the deny-by-default policy the gate enforces. Rendered into a ConfigMap and mounted read-only. Must parse as JSON and contain an \"actions\" array; an unparseable manifest fails at plan time, not in the cluster."
|
|
67
|
+
type = string
|
|
68
|
+
|
|
69
|
+
validation {
|
|
70
|
+
condition = can(jsondecode(var.manifest_json)) && can(jsondecode(var.manifest_json).actions)
|
|
71
|
+
error_message = "manifest_json must be valid JSON with an \"actions\" field (EP-ACTION-RISK-MANIFEST shape)."
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
variable "issuer_keys_secret_name" {
|
|
76
|
+
description = "Name of an EXISTING Kubernetes Secret (in the same namespace) holding the pinned issuer public keys the gate trusts. Referenced by name only — key material must NEVER be passed to Terraform. Exposed to the container as EP_GATE_ISSUER_KEYS via a non-optional secretKeyRef: pods do not start without it (fail closed — a gate with no pinned issuers must never come up permissive)."
|
|
77
|
+
type = string
|
|
78
|
+
|
|
79
|
+
validation {
|
|
80
|
+
# DNS-1123 subdomain. This also structurally rejects pasted key material
|
|
81
|
+
# (PEM headers, JSON, base64url with '_') landing in this variable.
|
|
82
|
+
condition = can(regex("^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$", var.issuer_keys_secret_name)) && length(var.issuer_keys_secret_name) <= 253
|
|
83
|
+
error_message = "issuer_keys_secret_name must be the NAME of an existing Secret (DNS-1123 subdomain). Never inline key material here."
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
variable "issuer_keys_secret_key" {
|
|
88
|
+
description = "Key inside the issuer-keys Secret that holds the pinned keys (a JSON array of base64url SPKI-DER Ed25519 public keys, or a {kid: key} map). Matches the Helm chart's issuerKeys.secretKey."
|
|
89
|
+
type = string
|
|
90
|
+
default = "issuer-keys.json"
|
|
91
|
+
|
|
92
|
+
validation {
|
|
93
|
+
condition = length(trimspace(var.issuer_keys_secret_key)) > 0
|
|
94
|
+
error_message = "issuer_keys_secret_key must be non-empty."
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
variable "port" {
|
|
99
|
+
description = "Port the gate HTTP service listens on inside the container (EP_GATE_PORT). Matches the Helm chart's gate.port."
|
|
100
|
+
type = number
|
|
101
|
+
default = 8080
|
|
102
|
+
|
|
103
|
+
validation {
|
|
104
|
+
condition = var.port >= 1 && var.port <= 65535
|
|
105
|
+
error_message = "port must be 1-65535."
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
variable "service_port" {
|
|
110
|
+
description = "Port the Service exposes inside the cluster."
|
|
111
|
+
type = number
|
|
112
|
+
default = 8080
|
|
113
|
+
|
|
114
|
+
validation {
|
|
115
|
+
condition = var.service_port >= 1 && var.service_port <= 65535
|
|
116
|
+
error_message = "service_port must be 1-65535."
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
variable "service_type" {
|
|
121
|
+
description = "Kubernetes Service type. ClusterIP (default) keeps the gate cluster-internal; anything more exposed is the deployer's explicit choice."
|
|
122
|
+
type = string
|
|
123
|
+
default = "ClusterIP"
|
|
124
|
+
|
|
125
|
+
validation {
|
|
126
|
+
condition = contains(["ClusterIP", "NodePort", "LoadBalancer"], var.service_type)
|
|
127
|
+
error_message = "service_type must be one of ClusterIP, NodePort, LoadBalancer."
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
variable "log_level" {
|
|
132
|
+
description = "Gate log verbosity (EP_GATE_LOG_LEVEL)."
|
|
133
|
+
type = string
|
|
134
|
+
default = "info"
|
|
135
|
+
|
|
136
|
+
validation {
|
|
137
|
+
condition = contains(["error", "warn", "info", "debug"], var.log_level)
|
|
138
|
+
error_message = "log_level must be one of error, warn, info, debug."
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
variable "evidence_strict" {
|
|
143
|
+
description = "Strict evidence log (EP_GATE_EVIDENCE_STRICT): when true the gate fails CLOSED if a decision record cannot be durably written — it never authorizes an action it cannot account for. Keep true."
|
|
144
|
+
type = bool
|
|
145
|
+
default = true
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
variable "metrics_enabled" {
|
|
149
|
+
description = "Sets EP_GATE_METRICS_ENABLED. Scrape wiring (ServiceMonitor etc.) is out of scope for this module — see the Helm chart."
|
|
150
|
+
type = bool
|
|
151
|
+
default = false
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
variable "liveness_path" {
|
|
155
|
+
description = "HTTP path for the liveness probe. Set to null to disable."
|
|
156
|
+
type = string
|
|
157
|
+
default = "/healthz"
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
variable "readiness_path" {
|
|
161
|
+
description = "HTTP path for the readiness probe. Set to null to disable."
|
|
162
|
+
type = string
|
|
163
|
+
default = "/readyz"
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
variable "resources" {
|
|
167
|
+
description = "Container resource requests/limits (Kubernetes quantity strings)."
|
|
168
|
+
type = object({
|
|
169
|
+
requests = map(string)
|
|
170
|
+
limits = map(string)
|
|
171
|
+
})
|
|
172
|
+
default = {
|
|
173
|
+
requests = { cpu = "100m", memory = "128Mi" }
|
|
174
|
+
limits = { cpu = "500m", memory = "256Mi" }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
variable "run_as_user" {
|
|
179
|
+
description = "UID/GID the gate container runs as (must be non-root; the pod security context enforces runAsNonRoot). Matches the Helm chart's default of 10001."
|
|
180
|
+
type = number
|
|
181
|
+
default = 10001
|
|
182
|
+
|
|
183
|
+
validation {
|
|
184
|
+
condition = var.run_as_user > 0
|
|
185
|
+
error_message = "run_as_user must be non-zero: the gate never runs as root."
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
variable "read_only_root_filesystem" {
|
|
190
|
+
description = "Mount the container's root filesystem read-only (hardened default). A writable emptyDir is mounted at /tmp for runtime scratch either way."
|
|
191
|
+
type = bool
|
|
192
|
+
default = true
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
variable "extra_env" {
|
|
196
|
+
description = "Additional plain-text environment variables for the gate container (e.g. a durable consumption-store URL host/port). Do NOT put secrets here — values in this map land in Terraform state. Reference deployer-managed Secrets instead."
|
|
197
|
+
type = map(string)
|
|
198
|
+
default = {}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
variable "extra_labels" {
|
|
202
|
+
description = "Additional labels merged onto every resource."
|
|
203
|
+
type = map(string)
|
|
204
|
+
default = {}
|
|
205
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
#
|
|
3
|
+
# EMILIA Gate — reference Terraform module (EP-GATE-TF-v1).
|
|
4
|
+
# Provider and core version constraints. The kubernetes provider is the ONLY
|
|
5
|
+
# provider this module uses: everything lands in the deployer's own cluster,
|
|
6
|
+
# authenticated with the deployer's own credentials. No EMILIA-hosted backend,
|
|
7
|
+
# no remote state requirement, no callbacks.
|
|
8
|
+
|
|
9
|
+
terraform {
|
|
10
|
+
required_version = ">= 1.5.0"
|
|
11
|
+
|
|
12
|
+
required_providers {
|
|
13
|
+
kubernetes = {
|
|
14
|
+
source = "hashicorp/kubernetes"
|
|
15
|
+
version = ">= 2.23.0, < 3.0.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
package/enterprise.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate — enterprise entitlement layer (EP-GATE-ENTITLEMENT-v1).
|
|
4
|
+
*
|
|
5
|
+
* The license key IS an EP-style artifact: a signed entitlement — Ed25519 over
|
|
6
|
+
* canonical JSON (sorted keys, same idiom as receipts/evidence) of
|
|
7
|
+
* { org, tier, features[], limits, not_before, expires_at, kid }. Verifiers pin
|
|
8
|
+
* issuer keys by kid; nothing in the artifact is trusted until the signature
|
|
9
|
+
* verifies against a pinned key.
|
|
10
|
+
*
|
|
11
|
+
* OPEN-CORE SEMANTICS — two different fail directions, by design:
|
|
12
|
+
* - The CORE gate is never bricked. No entitlement, an expired one, a
|
|
13
|
+
* tampered one, an unknown kid — all resolve to { valid:false,
|
|
14
|
+
* tier:'community' } with a machine-readable reason. Community tier always
|
|
15
|
+
* works; a licensing failure can never block the firewall itself.
|
|
16
|
+
* - Enterprise FEATURES fail closed. `requireFeature` returns true ONLY for
|
|
17
|
+
* a cryptographically valid, in-window entitlement that explicitly lists
|
|
18
|
+
* the feature. Everything else — including community fallback — is false.
|
|
19
|
+
*
|
|
20
|
+
* Pure functions: inputs in, verdict out. Time is injected (`now`), never read
|
|
21
|
+
* from the wall clock implicitly, so verification is deterministic.
|
|
22
|
+
*/
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
export const ENTITLEMENT_VERSION = 'EP-GATE-ENTITLEMENT-v1';
|
|
26
|
+
export const ENTITLEMENT_TIERS = ['community', 'team', 'business', 'enterprise', 'regulated'];
|
|
27
|
+
|
|
28
|
+
/** The tier every failure path resolves to — the gate keeps working on it. */
|
|
29
|
+
const COMMUNITY = 'community';
|
|
30
|
+
|
|
31
|
+
/** Canonical JSON (recursive sorted keys) — matches @emilia-protocol/verify. */
|
|
32
|
+
function canonical(v) {
|
|
33
|
+
if (v === null || v === undefined) return JSON.stringify(v);
|
|
34
|
+
if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
|
|
35
|
+
if (typeof v === 'object') {
|
|
36
|
+
return `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonical(v[k])).join(',')}}`;
|
|
37
|
+
}
|
|
38
|
+
return JSON.stringify(v);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toMs(t) {
|
|
42
|
+
if (t == null) return null;
|
|
43
|
+
const ms = typeof t === 'number' ? t : Date.parse(t);
|
|
44
|
+
return Number.isFinite(ms) ? ms : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Community fallback — every refusal shape is identical and machine-readable. */
|
|
48
|
+
function community(reason, extra = {}) {
|
|
49
|
+
return { valid: false, tier: COMMUNITY, features: [], limits: null, reason, ...extra };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mint a signed entitlement (test/ops helper — the licensing service, not the
|
|
54
|
+
* verifier, holds the private key). Throws on invalid fields: a malformed
|
|
55
|
+
* license must never be issued, only refused.
|
|
56
|
+
* @param {crypto.KeyObject} privateKey Ed25519 private key
|
|
57
|
+
* @param {object} fields { org, tier, features?, limits?, not_before, expires_at, kid }
|
|
58
|
+
* @returns {{ '@version': string, payload: object, signature: { algorithm: 'Ed25519', value: string } }}
|
|
59
|
+
*/
|
|
60
|
+
export function mintEntitlement(privateKey, {
|
|
61
|
+
org, tier, features = [], limits = {}, not_before, expires_at, kid,
|
|
62
|
+
} = {}) {
|
|
63
|
+
if (!org || typeof org !== 'string') throw new Error('entitlement: org is required');
|
|
64
|
+
if (!ENTITLEMENT_TIERS.includes(tier)) {
|
|
65
|
+
throw new Error(`entitlement: unknown tier "${tier}" (expected one of ${ENTITLEMENT_TIERS.join('|')})`);
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(features) || features.some((f) => typeof f !== 'string')) {
|
|
68
|
+
throw new Error('entitlement: features must be an array of strings');
|
|
69
|
+
}
|
|
70
|
+
if (!limits || typeof limits !== 'object' || Array.isArray(limits)) {
|
|
71
|
+
throw new Error('entitlement: limits must be an object');
|
|
72
|
+
}
|
|
73
|
+
if (toMs(not_before) == null) throw new Error('entitlement: not_before is required (ISO or ms)');
|
|
74
|
+
if (toMs(expires_at) == null) throw new Error('entitlement: expires_at is required (ISO or ms)');
|
|
75
|
+
if (!kid || typeof kid !== 'string') throw new Error('entitlement: kid is required');
|
|
76
|
+
const payload = { org, tier, features, limits, not_before, expires_at, kid };
|
|
77
|
+
const value = crypto.sign(null, Buffer.from(canonical(payload), 'utf8'), privateKey).toString('base64url');
|
|
78
|
+
return { '@version': ENTITLEMENT_VERSION, payload, signature: { algorithm: 'Ed25519', value } };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve a base64url SPKI-DER key for `kid` from a map or an entry list. */
|
|
82
|
+
function issuerKeyFor(issuerKeys, kid) {
|
|
83
|
+
if (!issuerKeys) return null;
|
|
84
|
+
if (Array.isArray(issuerKeys)) {
|
|
85
|
+
const e = issuerKeys.find((x) => x && x.kid === kid && typeof x.key === 'string');
|
|
86
|
+
return e ? e.key : null;
|
|
87
|
+
}
|
|
88
|
+
const k = issuerKeys[kid];
|
|
89
|
+
return typeof k === 'string' ? k : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Verify an entitlement. NEVER throws for a bad artifact — every failure
|
|
94
|
+
* resolves to the community tier with a machine-readable reason, so a licensing
|
|
95
|
+
* problem degrades gracefully instead of bricking the gate. Enterprise features
|
|
96
|
+
* remain gated by `requireFeature`, which fails closed on any non-valid result.
|
|
97
|
+
*
|
|
98
|
+
* @param {object|string|null} entitlementJson the artifact (object or JSON string); absence -> community
|
|
99
|
+
* @param {object} o
|
|
100
|
+
* @param {object|Array<{kid:string,key:string}>} o.issuerKeys pinned kid -> base64url SPKI-DER public key
|
|
101
|
+
* @param {number|string|function} [o.now=Date.now] injected clock (ms, ISO, or () => ms)
|
|
102
|
+
* @returns {{ valid: boolean, tier: string, features: string[], limits: object|null, reason: string, org?: string, kid?: string }}
|
|
103
|
+
*/
|
|
104
|
+
export function verifyEntitlement(entitlementJson, { issuerKeys, now = Date.now } = {}) {
|
|
105
|
+
// Absence is NOT an error: the open-core floor. Community keeps working.
|
|
106
|
+
if (entitlementJson == null || entitlementJson === '') return community('no_entitlement');
|
|
107
|
+
|
|
108
|
+
let doc = entitlementJson;
|
|
109
|
+
if (typeof doc === 'string') {
|
|
110
|
+
try { doc = JSON.parse(doc); } catch { return community('entitlement_unparseable'); }
|
|
111
|
+
}
|
|
112
|
+
if (!doc || typeof doc !== 'object') return community('entitlement_malformed');
|
|
113
|
+
if (doc['@version'] !== ENTITLEMENT_VERSION) return community('unsupported_version');
|
|
114
|
+
|
|
115
|
+
const p = doc.payload;
|
|
116
|
+
const sig = doc.signature;
|
|
117
|
+
if (!p || typeof p !== 'object' || !sig || typeof sig !== 'object') return community('entitlement_malformed');
|
|
118
|
+
if (sig.algorithm !== 'Ed25519' || typeof sig.value !== 'string') return community('unsupported_algorithm');
|
|
119
|
+
if (!ENTITLEMENT_TIERS.includes(p.tier)) return community('unknown_tier');
|
|
120
|
+
if (!Array.isArray(p.features) || p.features.some((f) => typeof f !== 'string')) return community('entitlement_malformed');
|
|
121
|
+
|
|
122
|
+
// Issuer pinning: the kid must resolve to a PINNED key. An entitlement can
|
|
123
|
+
// never nominate its own key — unknown kid (or no pins at all) fails closed.
|
|
124
|
+
const keyB64 = issuerKeyFor(issuerKeys, p.kid);
|
|
125
|
+
if (!keyB64) return community('unknown_kid', { kid: p.kid ?? null });
|
|
126
|
+
|
|
127
|
+
let ok = false;
|
|
128
|
+
try {
|
|
129
|
+
const pub = crypto.createPublicKey({ key: Buffer.from(keyB64, 'base64url'), format: 'der', type: 'spki' });
|
|
130
|
+
ok = crypto.verify(null, Buffer.from(canonical(p), 'utf8'), pub, Buffer.from(sig.value, 'base64url'));
|
|
131
|
+
} catch { ok = false; }
|
|
132
|
+
// One reason covers both tampering and a wrong key: the signature does not
|
|
133
|
+
// verify against the pinned key for this kid.
|
|
134
|
+
if (!ok) return community('bad_signature', { kid: p.kid });
|
|
135
|
+
|
|
136
|
+
// Validity window — checked only AFTER the signature, so the timestamps
|
|
137
|
+
// themselves are authenticated. Both bounds are required; an unparseable
|
|
138
|
+
// window fails closed.
|
|
139
|
+
const nowMs = typeof now === 'function' ? now() : toMs(now);
|
|
140
|
+
const nbf = toMs(p.not_before);
|
|
141
|
+
const exp = toMs(p.expires_at);
|
|
142
|
+
if (nbf == null || exp == null || nowMs == null) return community('invalid_validity_window', { kid: p.kid });
|
|
143
|
+
if (nowMs < nbf) return community('not_yet_valid', { kid: p.kid, not_before: p.not_before });
|
|
144
|
+
if (nowMs > exp) return community('expired', { kid: p.kid, expires_at: p.expires_at });
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
valid: true,
|
|
148
|
+
tier: p.tier,
|
|
149
|
+
features: p.features.slice(),
|
|
150
|
+
limits: (p.limits && typeof p.limits === 'object') ? { ...p.limits } : {},
|
|
151
|
+
reason: 'entitlement_verified',
|
|
152
|
+
org: p.org,
|
|
153
|
+
kid: p.kid,
|
|
154
|
+
not_before: p.not_before,
|
|
155
|
+
expires_at: p.expires_at,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Is `feature` licensed? FAIL CLOSED: true only for a valid entitlement that
|
|
161
|
+
* explicitly lists the feature. Community fallback, invalid/expired/tampered
|
|
162
|
+
* artifacts, and unlisted features are all false — no enterprise code path
|
|
163
|
+
* runs without a live license naming it.
|
|
164
|
+
* @param {object} verified the result of verifyEntitlement
|
|
165
|
+
* @param {string} feature
|
|
166
|
+
* @returns {boolean}
|
|
167
|
+
*/
|
|
168
|
+
export function requireFeature(verified, feature) {
|
|
169
|
+
if (!verified || verified.valid !== true) return false;
|
|
170
|
+
if (typeof feature !== 'string' || feature.length === 0) return false;
|
|
171
|
+
return Array.isArray(verified.features) && verified.features.includes(feature);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export default { mintEntitlement, verifyEntitlement, requireFeature, ENTITLEMENT_VERSION, ENTITLEMENT_TIERS };
|