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.
- checksums.yaml +4 -4
- data/LICENSE +201 -0
- data/README.md +439 -2
- data/exe/knoxcall +8 -0
- data/lib/knoxcall/bootstrap.rb +70 -0
- data/lib/knoxcall/bound_route.rb +47 -0
- data/lib/knoxcall/cli/ai.rb +79 -0
- data/lib/knoxcall/cli/ai_control.rb +275 -0
- data/lib/knoxcall/cli/common.rb +96 -0
- data/lib/knoxcall/cli/init.rb +94 -0
- data/lib/knoxcall/cli/login.rb +306 -0
- data/lib/knoxcall/cli/logout.rb +41 -0
- data/lib/knoxcall/cli/whoami.rb +29 -0
- data/lib/knoxcall/cli.rb +377 -0
- data/lib/knoxcall/client.rb +1025 -0
- data/lib/knoxcall/credentials_file.rb +442 -0
- data/lib/knoxcall/dpop.rb +79 -0
- data/lib/knoxcall/egress_observations.rb +372 -0
- data/lib/knoxcall/errors.rb +304 -0
- data/lib/knoxcall/intercept_patch.rb +181 -0
- data/lib/knoxcall/intercept_pipeline.rb +455 -0
- data/lib/knoxcall/intercept_resolver.rb +140 -0
- data/lib/knoxcall/intercept_store.rb +203 -0
- data/lib/knoxcall/login.rb +144 -0
- data/lib/knoxcall/resources/account.rb +12 -0
- data/lib/knoxcall/resources/agents.rb +25 -0
- data/lib/knoxcall/resources/ai_gateway.rb +417 -0
- data/lib/knoxcall/resources/api_keys.rb +35 -0
- data/lib/knoxcall/resources/audit_logs.rb +45 -0
- data/lib/knoxcall/resources/clients.rb +39 -0
- data/lib/knoxcall/resources/crypto.rb +122 -0
- data/lib/knoxcall/resources/dynamic_db.rb +68 -0
- data/lib/knoxcall/resources/environments.rb +16 -0
- data/lib/knoxcall/resources/logs.rb +51 -0
- data/lib/knoxcall/resources/oauth_clients.rb +34 -0
- data/lib/knoxcall/resources/opportunities.rb +61 -0
- data/lib/knoxcall/resources/pki.rb +41 -0
- data/lib/knoxcall/resources/roles.rb +27 -0
- data/lib/knoxcall/resources/routes.rb +53 -0
- data/lib/knoxcall/resources/secrets.rb +98 -0
- data/lib/knoxcall/resources/unwraps_envelope.rb +90 -0
- data/lib/knoxcall/resources/vaults.rb +77 -0
- data/lib/knoxcall/resources/webhooks.rb +48 -0
- data/lib/knoxcall/resources/workflows.rb +86 -0
- data/lib/knoxcall/resources/wrap.rb +352 -0
- data/lib/knoxcall/route_refusal.rb +67 -0
- data/lib/knoxcall/signup.rb +122 -0
- data/lib/knoxcall/token_exchange.rb +169 -0
- data/lib/knoxcall/ulid.rb +19 -0
- data/lib/knoxcall/warnings.rb +63 -0
- data/lib/knoxcall/workload_provider.rb +192 -0
- data/lib/knoxcall/wrap_faraday_adapter.rb +119 -0
- data/lib/knoxcall/wrap_faraday_middleware.rb +67 -0
- data/lib/knoxcall/wrap_transport.rb +139 -0
- data/lib/knoxcall.rb +45 -1
- 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
|