knoxcall 0.0.1 → 1.0.0

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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +201 -0
  3. data/README.md +439 -2
  4. data/exe/knoxcall +8 -0
  5. data/lib/knoxcall/bootstrap.rb +70 -0
  6. data/lib/knoxcall/bound_route.rb +47 -0
  7. data/lib/knoxcall/cli/ai.rb +79 -0
  8. data/lib/knoxcall/cli/ai_control.rb +275 -0
  9. data/lib/knoxcall/cli/common.rb +96 -0
  10. data/lib/knoxcall/cli/init.rb +94 -0
  11. data/lib/knoxcall/cli/login.rb +306 -0
  12. data/lib/knoxcall/cli/logout.rb +41 -0
  13. data/lib/knoxcall/cli/whoami.rb +29 -0
  14. data/lib/knoxcall/cli.rb +377 -0
  15. data/lib/knoxcall/client.rb +1025 -0
  16. data/lib/knoxcall/credentials_file.rb +442 -0
  17. data/lib/knoxcall/dpop.rb +79 -0
  18. data/lib/knoxcall/egress_observations.rb +372 -0
  19. data/lib/knoxcall/errors.rb +304 -0
  20. data/lib/knoxcall/intercept_patch.rb +181 -0
  21. data/lib/knoxcall/intercept_pipeline.rb +455 -0
  22. data/lib/knoxcall/intercept_resolver.rb +140 -0
  23. data/lib/knoxcall/intercept_store.rb +203 -0
  24. data/lib/knoxcall/login.rb +144 -0
  25. data/lib/knoxcall/resources/account.rb +12 -0
  26. data/lib/knoxcall/resources/agents.rb +25 -0
  27. data/lib/knoxcall/resources/ai_gateway.rb +417 -0
  28. data/lib/knoxcall/resources/api_keys.rb +35 -0
  29. data/lib/knoxcall/resources/audit_logs.rb +45 -0
  30. data/lib/knoxcall/resources/clients.rb +39 -0
  31. data/lib/knoxcall/resources/crypto.rb +122 -0
  32. data/lib/knoxcall/resources/dynamic_db.rb +68 -0
  33. data/lib/knoxcall/resources/environments.rb +16 -0
  34. data/lib/knoxcall/resources/logs.rb +51 -0
  35. data/lib/knoxcall/resources/oauth_clients.rb +34 -0
  36. data/lib/knoxcall/resources/opportunities.rb +61 -0
  37. data/lib/knoxcall/resources/pki.rb +41 -0
  38. data/lib/knoxcall/resources/roles.rb +27 -0
  39. data/lib/knoxcall/resources/routes.rb +53 -0
  40. data/lib/knoxcall/resources/secrets.rb +98 -0
  41. data/lib/knoxcall/resources/unwraps_envelope.rb +90 -0
  42. data/lib/knoxcall/resources/vaults.rb +77 -0
  43. data/lib/knoxcall/resources/webhooks.rb +48 -0
  44. data/lib/knoxcall/resources/workflows.rb +86 -0
  45. data/lib/knoxcall/resources/wrap.rb +352 -0
  46. data/lib/knoxcall/route_refusal.rb +67 -0
  47. data/lib/knoxcall/signup.rb +122 -0
  48. data/lib/knoxcall/token_exchange.rb +169 -0
  49. data/lib/knoxcall/ulid.rb +19 -0
  50. data/lib/knoxcall/warnings.rb +63 -0
  51. data/lib/knoxcall/workload_provider.rb +192 -0
  52. data/lib/knoxcall/wrap_faraday_adapter.rb +119 -0
  53. data/lib/knoxcall/wrap_faraday_middleware.rb +67 -0
  54. data/lib/knoxcall/wrap_transport.rb +139 -0
  55. data/lib/knoxcall.rb +45 -1
  56. metadata +70 -9
@@ -0,0 +1,122 @@
1
+ module KnoxCall
2
+ module Resources
3
+ class Crypto
4
+ include UnwrapsEnvelope
5
+
6
+ # Sentinel so #inspect can double as Object#inspect when called with no
7
+ # argument (consoles / p / rspec output) — see #inspect below.
8
+ UNSET = Object.new.freeze
9
+ private_constant :UNSET
10
+
11
+ def initialize(client) = @client = client
12
+
13
+ # -- Key management --
14
+
15
+ # Bare array — no pagination.
16
+ def list_keys = unwrap(@client.request("GET", "/v1/crypto/keys"))
17
+ def get_key(name) = unwrap(@client.request("GET", "/v1/crypto/keys/#{encode(name)}"))
18
+ def create_key(**input) = unwrap(@client.request("POST", "/v1/crypto/keys", body: input))
19
+ # Raise or lower the key's destroy safety latch. A version can only be
20
+ # destroyed while +deletion_allowed+ is true, and every new key ships with
21
+ # it false; destroy_key_version on a latched key is refused with a 409
22
+ # (deletion_not_allowed), which is a client error and must not be retried
23
+ # unchanged. Returns {"name" => String, "deletion_allowed" => Boolean}.
24
+ def update_key(name, deletion_allowed:) = unwrap(@client.request("PATCH", "/v1/crypto/keys/#{encode(name)}", body: { deletion_allowed: deletion_allowed }))
25
+ # Returns {"new_version" => Integer}.
26
+ def rotate_key(name) = unwrap(@client.request("POST", "/v1/crypto/keys/#{encode(name)}/rotate", body: {}))
27
+ # Returns {"destroyed" => Integer} — the destroyed version.
28
+ def destroy_key_version(name, version) = unwrap(@client.request("DELETE", "/v1/crypto/keys/#{encode(name)}/versions/#{version}"))
29
+ # Returns {"pem", "jwk", "key_version"}. version travels as a query param.
30
+ def get_public_key(name, version: nil)
31
+ query = version ? { version: version } : nil
32
+ unwrap(@client.request("GET", "/v1/crypto/keys/#{encode(name)}/public-key", query: query))
33
+ end
34
+
35
+ # -- Keyed transit encryption --
36
+
37
+ # Returns {"ciphertext", "key_version"}.
38
+ def encrypt(name, **input) = unwrap(@client.request("POST", "/v1/crypto/keys/#{encode(name)}/encrypt", body: input))
39
+ # Returns {"plaintext_b64", "key_version"} by default; pass
40
+ # format: "utf8" (a query param) for a decoded "plaintext" instead.
41
+ def decrypt(name, ciphertext:, format: nil)
42
+ query = format ? { format: format } : nil
43
+ unwrap(@client.request("POST", "/v1/crypto/keys/#{encode(name)}/decrypt", query: query, body: { ciphertext: ciphertext }))
44
+ end
45
+ # Returns {"ciphertext", "key_version"}.
46
+ def rewrap(name, ciphertext:) = unwrap(@client.request("POST", "/v1/crypto/keys/#{encode(name)}/rewrap", body: { ciphertext: ciphertext }))
47
+
48
+ # -- Portable kc: encryption (structure-preserving, top-level /v1) --
49
+ # Distinct from the keyed transit encrypt above: these take arbitrary
50
+ # JSON and return the same shape with scalar leaves swapped for
51
+ # portable, self-describing `kc:` ciphertext strings.
52
+
53
+ # Returns {"ciphertext" => <same shape, leaves encrypted>, "key", "key_version"}.
54
+ def encrypt_data(data, key: nil, role: nil)
55
+ body = { data: data }
56
+ body[:key] = key if key
57
+ body[:role] = role if role
58
+ unwrap(@client.request("POST", "/v1/encrypt", body: body))
59
+ end
60
+
61
+ # Returns {"plaintext" => <same shape, leaves decrypted>}.
62
+ def decrypt_data(data, role: nil)
63
+ body = { data: data }
64
+ body[:role] = role if role
65
+ unwrap(@client.request("POST", "/v1/decrypt", body: body))
66
+ end
67
+
68
+ # Metadata about a single `kc:` ciphertext string — no decryption.
69
+ # Returns {"encrypted", "scheme", "version", "datatype", "key_ref", "fingerprint"}.
70
+ # With no argument this behaves as Object#inspect so consoles still work.
71
+ def inspect(value = UNSET)
72
+ return super() if value.equal?(UNSET)
73
+ unwrap(@client.request("POST", "/v1/inspect", body: { value: value }))
74
+ end
75
+
76
+ # Mint a single-use, payload-pinned client-side capability token. Hand
77
+ # the returned "token" to a browser/agent so it can reveal exactly the
78
+ # bound data (a kc: ciphertext for "decrypt", a vault token for
79
+ # "detokenize") once, via POST /v1/client/{decrypt,detokenize}, without
80
+ # an API key. input: action:, data:, plus optional role:, ttl_seconds:.
81
+ # Returns {"token", "expires_at", "action"}.
82
+ def mint_client_token(**input) = unwrap(@client.request("POST", "/v1/client-tokens", body: input))
83
+
84
+ # The public bits a browser needs to seal values client-side (public
85
+ # key + key_ref). Your backend calls this and hands the JSON to the
86
+ # page; no private material is included.
87
+ def get_sealing_bundle(key: nil)
88
+ query = key ? { key: key } : nil
89
+ unwrap(@client.request("GET", "/v1/encrypt/sealing-bundle", query: query))
90
+ end
91
+
92
+ # -- Signing --
93
+
94
+ # Returns {"signature", "key_version"}.
95
+ def sign(name, **input) = unwrap(@client.request("POST", "/v1/crypto/keys/#{encode(name)}/sign", body: input))
96
+ # Returns {"valid", "key_version"}.
97
+ def verify(name, **input) = unwrap(@client.request("POST", "/v1/crypto/keys/#{encode(name)}/verify", body: input))
98
+
99
+ # -- JWT --
100
+
101
+ # Returns {"token", "key_version", "alg"}.
102
+ def sign_jwt(name, claims, header_overrides: nil)
103
+ body = { claims: claims }
104
+ body[:header_overrides] = header_overrides if header_overrides
105
+ unwrap(@client.request("POST", "/v1/crypto/keys/#{encode(name)}/jwt", body: body))
106
+ end
107
+ # Returns {"valid"} plus, when valid, "claims"/"key_version"/"alg"/"kid"
108
+ # (or "error" when not).
109
+ def verify_jwt(name, token, expected: nil)
110
+ body = { token: token }
111
+ body[:expected] = expected if expected
112
+ unwrap(@client.request("POST", "/v1/crypto/keys/#{encode(name)}/jwt/verify", body: body))
113
+ end
114
+
115
+ # -- Webhook signing --
116
+
117
+ # Returns {"signature_header" ("t=<unix>,v1=<hex>"), "timestamp_seconds",
118
+ # "key_version", "format" ("stripe")}.
119
+ def sign_webhook(name, **input) = unwrap(@client.request("POST", "/v1/crypto/keys/#{encode(name)}/webhook-sign", body: input))
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,68 @@
1
+ module KnoxCall
2
+ module Resources
3
+ class DynamicDb
4
+ include UnwrapsEnvelope
5
+
6
+ def initialize(client) = @client = client
7
+
8
+ # -- Connections --
9
+
10
+ # Bare array — no pagination.
11
+ def list = unwrap(@client.request("GET", "/v1/dyn-db-credentials"))
12
+ def get(name) = unwrap(@client.request("GET", "/v1/dyn-db-credentials/#{encode(name)}"))
13
+ def create(**input) = unwrap(@client.request("POST", "/v1/dyn-db-credentials", body: input))
14
+ # Returns {"updated" => <name String>}.
15
+ def update(name, **input) = unwrap(@client.request("PATCH", "/v1/dyn-db-credentials/#{encode(name)}", body: input))
16
+ # Returns {"deleted" => <name String>} (NOT a boolean — server contract).
17
+ def delete(name) = unwrap(@client.request("DELETE", "/v1/dyn-db-credentials/#{encode(name)}"))
18
+ # Returns {"rotated", "fingerprint_updated"}.
19
+ def rotate_ssh_key(name, ssh_private_key:, ssh_passphrase: nil, ssh_host_fingerprint: nil)
20
+ body = { ssh_private_key: ssh_private_key }
21
+ body[:ssh_passphrase] = ssh_passphrase if ssh_passphrase
22
+ body[:ssh_host_fingerprint] = ssh_host_fingerprint if ssh_host_fingerprint
23
+ unwrap(@client.request("POST", "/v1/dyn-db-credentials/#{encode(name)}/rotate-ssh-key", body: body))
24
+ end
25
+
26
+ # -- Roles --
27
+
28
+ # Bare array — no pagination.
29
+ def list_roles(connection_name) = unwrap(@client.request("GET", "/v1/dyn-db-credentials/#{encode(connection_name)}/roles"))
30
+ def create_role(connection_name, **input) = unwrap(@client.request("POST", "/v1/dyn-db-credentials/#{encode(connection_name)}/roles", body: input))
31
+ # Returns {"updated" => <role String>}.
32
+ def update_role(connection_name, role, **input) = unwrap(@client.request("PATCH", "/v1/dyn-db-credentials/#{encode(connection_name)}/roles/#{encode(role)}", body: input))
33
+ # Returns {"deleted" => <role String>}.
34
+ def delete_role(connection_name, role) = unwrap(@client.request("DELETE", "/v1/dyn-db-credentials/#{encode(connection_name)}/roles/#{encode(role)}"))
35
+
36
+ # -- Credential minting + leases --
37
+
38
+ # The response's "password" is shown exactly once — store it now.
39
+ # Returns {"username", "password", "expires_at", "lease_id" (Integer),
40
+ # "connection_name", "role_name"}.
41
+ def mint(connection_name, role, ttl_seconds: nil)
42
+ body = ttl_seconds ? { ttl_seconds: ttl_seconds } : {}
43
+ unwrap(@client.request("POST", "/v1/dyn-db-credentials/#{encode(connection_name)}/creds/#{encode(role)}", body: body))
44
+ end
45
+ # Returns {"leases" => [...], "total", "limit", "offset"} — this list
46
+ # really is limit/offset (inside data), the one list on the API that is
47
+ # neither page/per_page nor a bare array.
48
+ #
49
+ # Only LIVE leases are returned — status "active", "renewing" or
50
+ # "errored", i.e. every lease whose database user may still exist on your
51
+ # server. Expired and revoked leases have had their user dropped and are
52
+ # neither listed nor counted in "total". +connection+ is an exact match on
53
+ # the connection's name, scoped to the caller's Live/Test space.
54
+ #
55
+ # "errored" is included deliberately: renewal was abandoned after five
56
+ # consecutive failures, so nothing is refreshing or expiring that lease.
57
+ # It is also the set counted by the 409 "has N active credential lease(s)"
58
+ # a connection or role delete returns, so anything blocking a delete is
59
+ # listed here and can be revoked.
60
+ def list_leases(limit: nil, offset: nil, connection: nil)
61
+ q = { limit: limit, offset: offset, connection: connection }.compact
62
+ unwrap(@client.request("GET", "/v1/dyn-db-credentials/leases", query: q.empty? ? nil : q))
63
+ end
64
+ # Returns {"revoked" => <lease_id Integer>}.
65
+ def revoke_lease(lease_id) = unwrap(@client.request("POST", "/v1/dyn-db-credentials/leases/#{encode(lease_id)}/revoke", body: {}))
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,16 @@
1
+ module KnoxCall
2
+ module Resources
3
+ class Environments
4
+ include UnwrapsEnvelope
5
+
6
+ def initialize(client) = @client = client
7
+
8
+ # Bare array — no pagination.
9
+ def list = unwrap(@client.request("GET", "/v1/environments"))
10
+ def create(**input) = unwrap(@client.request("POST", "/v1/environments", body: input))
11
+ def update(env_id, **input) = unwrap(@client.request("PATCH", "/v1/environments/#{encode(env_id)}", body: input))
12
+ # Returns {"deleted" => true}.
13
+ def delete(env_id) = unwrap(@client.request("DELETE", "/v1/environments/#{encode(env_id)}"))
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,51 @@
1
+ module KnoxCall
2
+ module Resources
3
+ # Request Logs — the per-call proxy log, and Merkle inclusion proofs.
4
+ #
5
+ # Distinct from {AuditLogs}, which is the CHANGE log (who edited what).
6
+ # This is the record of requests that went THROUGH the proxy, and {#proof}
7
+ # is the evidence that a given entry existed, unaltered, when it was
8
+ # anchored.
9
+ class Logs
10
+ include UnwrapsEnvelope
11
+
12
+ def initialize(client) = @client = client
13
+
14
+ # One page of the request log feed. Params: +cursor+, +limit+,
15
+ # +route_id+, +status_code+. Returns the {data, meta} envelope.
16
+ #
17
+ # Keyset, not offset: ordering is ascending +cursor+ and stable across
18
+ # calls. Delivery is AT LEAST ONCE — dedupe on +request_id+.
19
+ # +meta.next_cursor+ is OPAQUE; pass it back verbatim. A +nil+
20
+ # +next_cursor+ means the feed is drained to the watermark, NOT that it
21
+ # has ended.
22
+ #
23
+ # Captured request/response BODIES are never returned here at any
24
+ # permission level. The identity fields (+src_ip+, +matched_client_id+,
25
+ # +identification_method+) require +log:read_identity+ and are ABSENT
26
+ # rather than nil when denied; +meta._redacted+ says so.
27
+ def list(**params) = @client.request("GET", "/v1/logs", query: params.empty? ? nil : params)
28
+
29
+ # Yield every row, walking the cursor transparently until the feed is
30
+ # drained to the watermark. Returns a lazy Enumerator when no block is
31
+ # given.
32
+ def each(**params, &block)
33
+ enum = paginate_cursor(params) { |p| list(**p) }
34
+ block ? enum.each(&block) : enum
35
+ end
36
+
37
+ # One request by its +request_id+ (the +X-Request-Id+ header value).
38
+ def get(request_id) = unwrap(@client.request("GET", "/v1/logs/#{encode(request_id)}"))
39
+
40
+ # Merkle inclusion proof for one request.
41
+ #
42
+ # Returns for every outcome — read +anchored+ and +verified+ rather than
43
+ # rescuing. +verified: false+ with +reason: "range_incomplete"+ is the
44
+ # expected result for an old entry whose anchored range has since been
45
+ # trimmed by retention, and is NOT a sign of tampering;
46
+ # +"root_mismatch"+ is. A proof is never returned alongside a failed
47
+ # verification.
48
+ def proof(request_id) = unwrap(@client.request("GET", "/v1/logs/#{encode(request_id)}/proof"))
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,34 @@
1
+ module KnoxCall
2
+ module Resources
3
+ # OAuth client registry. These endpoints bypass the standard success()
4
+ # wrapper server-side: `data` has no accompanying meta, the list is NOT
5
+ # paginated, and create/rotate_secret carry a top-level `warning` string —
6
+ # surfaced here as an optional "warning" key on the returned Hash.
7
+ class OAuthClients
8
+ include UnwrapsEnvelope
9
+
10
+ def initialize(client) = @client = client
11
+
12
+ # Bare array — no pagination.
13
+ def list = unwrap(@client.request("GET", "/v1/oauth-clients"))
14
+ def get(id) = unwrap(@client.request("GET", "/v1/oauth-clients/#{encode(id)}"))
15
+ # The response's "client_secret" (nil for public clients) is shown
16
+ # exactly once — store it now. A server-side warning, when present, is
17
+ # attached as the "warning" key.
18
+ def create(**input) = unwrap_with_warning(@client.request("POST", "/v1/oauth-clients", body: input))
19
+ # Returns {"id" => String}.
20
+ def update(id, **input) = unwrap(@client.request("PATCH", "/v1/oauth-clients/#{encode(id)}", body: input))
21
+ # The new "client_secret" is shown exactly once. The server's warning
22
+ # is attached as the "warning" key.
23
+ #
24
+ # Rotation is containment: every access and refresh token the OLD secret
25
+ # minted is revoked in the same transaction as the re-key, so they stop
26
+ # working immediately rather than at their TTL. A rotation whose
27
+ # revocation cannot complete is refused and the old secret keeps working —
28
+ # you never hold a new secret for a client whose old tokens are still live.
29
+ def rotate_secret(id) = unwrap_with_warning(@client.request("POST", "/v1/oauth-clients/#{encode(id)}/rotate-secret", body: {}))
30
+ # Returns {"revoked" => true}.
31
+ def revoke(id) = unwrap(@client.request("DELETE", "/v1/oauth-clients/#{encode(id)}"))
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,61 @@
1
+ module KnoxCall
2
+ module Resources
3
+ # Promotion opportunities (GET/POST /v1/opportunities) — mirrors
4
+ # src/client-api/opportunities.ts.
5
+ #
6
+ # "We detected outbound API usage → create a route", from two sources
7
+ # (agent_monitor + gateway_traffic). #list refreshes gateway detection on
8
+ # read; #accept promotes a suggestion to a durable route + secret binding;
9
+ # #dismiss drops a pending suggestion.
10
+ #
11
+ # Each opportunity row is
12
+ # {id, source ("agent_monitor"|"gateway_traffic"), service,
13
+ # destination_host, status ("pending"|"snoozed"|"onboarded"|"dismissed"),
14
+ # confidence, suggested_route_json, evidence_json, accepted_route_id,
15
+ # created_at, updated_at, acted_at}.
16
+ class Opportunities
17
+ include UnwrapsEnvelope
18
+
19
+ def initialize(client) = @client = client
20
+
21
+ # List promotion opportunities (refreshes gateway detection on read).
22
+ # Paginated. Params: status ("pending"|"snoozed"|"onboarded"|
23
+ # "dismissed"), page (default 1), per_page (default 20, max 100).
24
+ # Returns the {data, meta} envelope.
25
+ def list(**params) = @client.request("GET", "/v1/opportunities", query: params.empty? ? nil : params)
26
+
27
+ # Yield every opportunity, walking pages transparently. Returns a lazy
28
+ # Enumerator when no block is given.
29
+ def each(**params, &block)
30
+ enum = paginate(params) { |p| list(**p) }
31
+ block ? enum.each(&block) : enum
32
+ end
33
+
34
+ # Promote a gateway suggestion to a durable route + secret binding.
35
+ #
36
+ # All body fields are optional: +collection_name+ (else the suggestion's,
37
+ # else "Wrapped APIs"), +environment+ (else the tenant default), +secret+
38
+ # (name/id to bind, else an escrowed wrap credential for the host is
39
+ # auto-bound), +header_name+ (default Authorization) and +value_prefix+
40
+ # (default "Bearer ").
41
+ #
42
+ # @return [Hash] unwrapped {opportunity_id, route: {id, slug, name},
43
+ # collection_id, environment}
44
+ def accept(id, collection_name: nil, environment: nil, secret: nil,
45
+ header_name: nil, value_prefix: nil)
46
+ body = {}
47
+ body[:collection_name] = collection_name unless collection_name.nil?
48
+ body[:environment] = environment unless environment.nil?
49
+ body[:secret] = secret unless secret.nil?
50
+ body[:header_name] = header_name unless header_name.nil?
51
+ body[:value_prefix] = value_prefix unless value_prefix.nil?
52
+ unwrap(@client.request("POST", "/v1/opportunities/#{encode(id)}/accept", body: body))
53
+ end
54
+
55
+ # Dismiss a pending suggestion.
56
+ #
57
+ # @return [Hash] unwrapped {opportunity_id, status: "dismissed"}
58
+ def dismiss(id) = unwrap(@client.request("POST", "/v1/opportunities/#{encode(id)}/dismiss"))
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,41 @@
1
+ module KnoxCall
2
+ module Resources
3
+ class Pki
4
+ include UnwrapsEnvelope
5
+
6
+ def initialize(client) = @client = client
7
+
8
+ # Bare array of CA roots — no pagination.
9
+ def list_roots = unwrap(@client.request("GET", "/v1/pki/roots"))
10
+ # Returns {"root" => CARoot, "intermediate_not_after" => ISO string}.
11
+ def create_root(name, subject) = unwrap(@client.request("POST", "/v1/pki/roots", body: { name: name, subject: subject }))
12
+ # Raw PEM text (text/x-pem-file) — no JSON wrapper.
13
+ def get_root_cert(name) = raw_text(@client.request("GET", "/v1/pki/roots/#{encode(name)}/cert"), "CA certificate")
14
+ # Returns {"intermediate_id", "not_after"}.
15
+ def rotate_intermediate(name) = unwrap(@client.request("POST", "/v1/pki/roots/#{encode(name)}/rotate-intermediate", body: {}))
16
+ # Raw CRL text — no JSON wrapper.
17
+ def get_crl(name) = raw_text(@client.request("GET", "/v1/pki/roots/#{encode(name)}/crl"), "CRL")
18
+ # Bare array of roles — no pagination.
19
+ def list_roles(root_name) = unwrap(@client.request("GET", "/v1/pki/roots/#{encode(root_name)}/roles"))
20
+ def create_role(root_name, **input) = unwrap(@client.request("POST", "/v1/pki/roots/#{encode(root_name)}/roles", body: input))
21
+ # Issue a leaf certificate under a role. The "private_key_pem" is shown
22
+ # exactly once — store it now. Returns {"serial_hex", "cert_pem",
23
+ # "private_key_pem", "ca_chain_pem", "not_before", "not_after"}.
24
+ def issue_cert(root_name, role, **input) = unwrap(@client.request("POST", "/v1/pki/roots/#{encode(root_name)}/issue/#{encode(role)}", body: input))
25
+ # Returns {"revoked" => Boolean}.
26
+ def revoke_cert(root_name, serial_hex, reason: nil)
27
+ body = { serial_hex: serial_hex }
28
+ body[:reason] = reason if reason
29
+ unwrap(@client.request("POST", "/v1/pki/roots/#{encode(root_name)}/revoke", body: body))
30
+ end
31
+
32
+ private
33
+
34
+ # The cert/CRL endpoints return raw text, not the JSON envelope.
35
+ def raw_text(response, what)
36
+ raise Error, "expected raw #{what} text but got a JSON response" unless response.is_a?(String)
37
+ response
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,27 @@
1
+ module KnoxCall
2
+ module Resources
3
+ # Read-only role catalog (IaC plan §6 item 1.3).
4
+ #
5
+ # Roles are created and edited on the MFA-gated admin surface; /v1 exposes
6
+ # them so `api_keys.create(role_ids: [...])` can be written in code instead
7
+ # of by copying a UUID out of a browser URL bar. The rules a role grants are
8
+ # deliberately not exposed.
9
+ class Roles
10
+ include UnwrapsEnvelope
11
+
12
+ def initialize(client) = @client = client
13
+
14
+ # Paginated. Params: subject_kind ("api_key" | "user"), page, per_page.
15
+ # Only roles whose applies_to includes "api_key" may be passed in
16
+ # `role_ids` when creating a key. Returns the {data, meta} envelope.
17
+ def list(**params) = @client.request("GET", "/v1/roles", query: params.empty? ? nil : params)
18
+
19
+ # Yield every role, walking pages transparently. Returns a lazy
20
+ # Enumerator when no block is given.
21
+ def each(**params, &block)
22
+ enum = paginate(params) { |p| list(**p) }
23
+ block ? enum.each(&block) : enum
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,53 @@
1
+ module KnoxCall
2
+ module Resources
3
+ class Routes
4
+ include UnwrapsEnvelope
5
+
6
+ def initialize(client) = @client = client
7
+
8
+ # Paginated. Params: page (default 1), per_page (default 20, max 100),
9
+ # plus endpoint filters. Returns the envelope:
10
+ # {"data" => [...], "meta" => {"total", "page", "per_page", "total_pages", "request_id"}}.
11
+ def list(**params) = @client.request("GET", "/v1/routes", query: params.empty? ? nil : params)
12
+
13
+ # Yield every route, walking pages transparently. Returns a lazy
14
+ # Enumerator when no block is given.
15
+ def each(**params, &block)
16
+ enum = paginate(params) { |p| list(**p) }
17
+ block ? enum.each(&block) : enum
18
+ end
19
+
20
+ def get(route_id) = unwrap(@client.request("GET", "/v1/routes/#{encode(route_id)}"))
21
+ def create(**input) = unwrap(@client.request("POST", "/v1/routes", body: input))
22
+ def update(route_id, **input) = unwrap(@client.request("PATCH", "/v1/routes/#{encode(route_id)}", body: input))
23
+ # Returns {"deleted" => true}.
24
+ def delete(route_id) = unwrap(@client.request("DELETE", "/v1/routes/#{encode(route_id)}"))
25
+
26
+ # Paginated request logs. Params: page, per_page, plus filters.
27
+ def get_logs(route_id, **params) = @client.request("GET", "/v1/routes/#{encode(route_id)}/logs", query: params.empty? ? nil : params)
28
+
29
+ # Yield every log row, walking pages transparently. Returns a lazy
30
+ # Enumerator when no block is given.
31
+ def each_log(route_id, **params, &block)
32
+ enum = paginate(params) { |p| get_logs(route_id, **p) }
33
+ block ? enum.each(&block) : enum
34
+ end
35
+
36
+ # Bare array — no pagination.
37
+ def list_environments(route_id) = unwrap(@client.request("GET", "/v1/routes/#{encode(route_id)}/environments"))
38
+ def upsert_environment(route_id, env_name, **input) = unwrap(@client.request("PUT", "/v1/routes/#{encode(route_id)}/environments/#{encode(env_name)}", body: input))
39
+ # Returns {"deleted" => true}.
40
+ def delete_environment(route_id, env_name) = unwrap(@client.request("DELETE", "/v1/routes/#{encode(route_id)}/environments/#{encode(env_name)}"))
41
+
42
+ # -- Relay field-actions (declarative field-level encrypt/decrypt/tokenize) --
43
+
44
+ # Bare array — no pagination.
45
+ def list_actions(route_id) = unwrap(@client.request("GET", "/v1/routes/#{encode(route_id)}/actions"))
46
+ # input: direction: ("request"|"response"), action: ("encrypt"|"decrypt"|
47
+ # "tokenize"|"detokenize"), selectors: [String], plus optional key_name:,
48
+ # data_role:, content_type:, sort_order:.
49
+ def create_action(route_id, **input) = unwrap(@client.request("POST", "/v1/routes/#{encode(route_id)}/actions", body: input))
50
+ def delete_action(route_id, action_id) = unwrap(@client.request("DELETE", "/v1/routes/#{encode(route_id)}/actions/#{encode(action_id)}"))
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,98 @@
1
+ module KnoxCall
2
+ module Resources
3
+ class Secrets
4
+ include UnwrapsEnvelope
5
+
6
+ def initialize(client) = @client = client
7
+
8
+ # Paginated. Params: page (default 1), per_page (default 20, max 100),
9
+ # plus endpoint filters. Returns the {data, meta} envelope.
10
+ def list(**params) = @client.request("GET", "/v1/secrets", query: params.empty? ? nil : params)
11
+
12
+ # Yield every secret, walking pages transparently. Returns a lazy
13
+ # Enumerator when no block is given.
14
+ def each(**params, &block)
15
+ enum = paginate(params) { |p| list(**p) }
16
+ block ? enum.each(&block) : enum
17
+ end
18
+
19
+ # Fetch one secret's metadata. The value is never returned.
20
+ #
21
+ # The result carries an "environments" array — one entry per environment
22
+ # holding a value, ordered by environment name, each
23
+ # {environment_name, value_version, updated_at, expires_at_override}.
24
+ # "value_version" counts genuine value writes for that environment
25
+ # (starting at 1) and moves ONLY when the stored value changes:
26
+ # rotations, admin value/certificate updates and platform-managed
27
+ # custodial key rotation. It deliberately does not move for OAuth2 token
28
+ # refreshes, expiry-override edits, certificate metadata re-parsing, or a
29
+ # re-encryption of the same plaintext under a new tenant key. Compare it
30
+ # with the version your own last write returned to detect a rotation
31
+ # performed outside your tooling — "updated_at" cannot do that, because
32
+ # non-value writes move it too.
33
+ def get(secret_id) = unwrap(@client.request("GET", "/v1/secrets/#{encode(secret_id)}"))
34
+ def create(**input) = unwrap(@client.request("POST", "/v1/secrets", body: input))
35
+
36
+ # Create an OAuth2-provider secret (the proxy injects the provider's access
37
+ # token into upstream requests). The base #create cannot carry these
38
+ # fields, so POST the typed /v1/secrets/oauth2 route. Requires +name+,
39
+ # +provider+ and +client_id+; for most grant types also supply either
40
+ # +client_secret+ or +mtls_certificate_id+.
41
+ def create_oauth2(name:, provider:, client_id:, client_secret: nil,
42
+ mtls_certificate_id: nil, scopes: nil, auth_url: nil,
43
+ token_url: nil, grant_type: nil, username: nil,
44
+ password: nil, collection_id: nil)
45
+ body = { name: name, provider: provider, client_id: client_id }
46
+ body[:client_secret] = client_secret unless client_secret.nil?
47
+ body[:mtls_certificate_id] = mtls_certificate_id unless mtls_certificate_id.nil?
48
+ body[:scopes] = scopes unless scopes.nil?
49
+ body[:auth_url] = auth_url unless auth_url.nil?
50
+ body[:token_url] = token_url unless token_url.nil?
51
+ body[:grant_type] = grant_type unless grant_type.nil?
52
+ body[:username] = username unless username.nil?
53
+ body[:password] = password unless password.nil?
54
+ body[:collection_id] = collection_id unless collection_id.nil?
55
+ unwrap(@client.request("POST", "/v1/secrets/oauth2", body: body))
56
+ end
57
+
58
+ # Create a certificate / mTLS secret. Requires +name+ and
59
+ # +certificate_content+ (PEM text, or base64 for binary formats);
60
+ # +certificate_type+ is one of pem|pfx|p12|crt|cer|key|pkcs7|p7b|p7c and
61
+ # defaults to "pem".
62
+ def create_certificate(name:, certificate_content:, private_key: nil,
63
+ passphrase: nil, certificate_type: "pem",
64
+ collection_id: nil)
65
+ body = { name: name, certificate_content: certificate_content,
66
+ certificate_type: certificate_type }
67
+ body[:private_key] = private_key unless private_key.nil?
68
+ body[:passphrase] = passphrase unless passphrase.nil?
69
+ body[:collection_id] = collection_id unless collection_id.nil?
70
+ unwrap(@client.request("POST", "/v1/secrets/certificate", body: body))
71
+ end
72
+
73
+ def update(secret_id, **input) = unwrap(@client.request("PATCH", "/v1/secrets/#{encode(secret_id)}", body: input))
74
+ # Returns {"deleted" => true}.
75
+ def delete(secret_id) = unwrap(@client.request("DELETE", "/v1/secrets/#{encode(secret_id)}"))
76
+
77
+ # Rotate a secret's value for one environment.
78
+ #
79
+ # "value_version" in the result is the environment's version AFTER this
80
+ # write — 1 when this call stored the environment's first value,
81
+ # otherwise the previous version plus one. It is the version this call
82
+ # produced, so storing it has no read-after-write race with a concurrent
83
+ # rotation; compare it later against #get's
84
+ # environments[].value_version to detect a rotation performed outside
85
+ # your tooling.
86
+ def set_value(secret_id, value:, environment: nil)
87
+ body = { value: value }
88
+ body[:environment] = environment if environment
89
+ unwrap(@client.request("PUT", "/v1/secrets/#{encode(secret_id)}/value", body: body))
90
+ end
91
+
92
+ def get_oauth_token(secret_id, environment: nil)
93
+ query = environment ? { environment: environment } : nil
94
+ unwrap(@client.request("GET", "/v1/secrets/#{encode(secret_id)}/oauth2/token", query: query))
95
+ end
96
+ end
97
+ end
98
+ end