Class: Insika::TokenStore
- Inherits:
-
Object
- Object
- Insika::TokenStore
- Defined in:
- lib/insika/token_store.rb
Overview
Multi-tenant credentials (WS1): per-tenant tokens plus the operator token, stored ONLY as SHA-256 hashes — the plaintext is returned once at issue time and is never persisted, logged or evented. Every resolution is a hash lookup, so reading the store yields nothing usable. Behind any Insika::Store, like the other domain stores.
A record describes ONE principal the edge can resolve a Bearer to:
role: "operator" (tenant_id nil — the historical single credential)
"tenant" (scoped to tenant_id)
status: "active" | "revoked"
A revoked record stops resolving immediately; a tenant's rotation is revoke-all + issue (the spec's rule — revoking one tenant's token never touches another tenant's: every cell is (id)- or (hash)-scoped cells).
Defined Under Namespace
Constant Summary collapse
- SCOPE =
"tenant_tokens"
Instance Method Summary collapse
-
#active_token_ids ⇒ Object
Every active token id (used by revoke_all).
-
#find(id) ⇒ Object
Active-token record for token_id; a revoked one reads WHO it was but not as resolvable.
-
#initialize(store:) ⇒ TokenStore
constructor
A new instance of TokenStore.
-
#issue(tenant_id: nil, label: "default") ⇒ Object
Issues a token for tenant_id (nil = an OPERATOR token).
-
#resolve(token) ⇒ Object
The edge resolution: -> Record (active) | nil.
-
#revoke(id) ⇒ Object
-> bool: true only for an ACTIVE record (revoking an already-revoked/unknown id is false — a no-op, never an error).
-
#revoke_all(tenant_id:) ⇒ Object
Revokes every ACTIVE token of a tenant; the hash-index cells stay (they resolve to a revoked record -> nil).
-
#rotate(tenant_id:, label: "default", now: Time.now) ⇒ Object
Rotation: revoke the tenant's active tokens, issue a fresh one.
Constructor Details
#initialize(store:) ⇒ TokenStore
Returns a new instance of TokenStore.
38 39 40 |
# File 'lib/insika/token_store.rb', line 38 def initialize(store:) @store = store end |
Instance Method Details
#active_token_ids ⇒ Object
Every active token id (used by revoke_all). Reads the id-keyed cells.
121 122 123 124 125 126 127 |
# File 'lib/insika/token_store.rb', line 121 def active_token_ids @store.list(SCOPE, RECORD_PREFIX).filter_map do |key| id = key.delete_prefix(RECORD_PREFIX) record = @store.get(SCOPE, record_key(id)) record && record["status"] == "active" ? id : nil end end |
#find(id) ⇒ Object
Active-token record for token_id; a revoked one reads WHO it was but not as resolvable. -> Record | nil.
64 65 66 67 68 69 |
# File 'lib/insika/token_store.rb', line 64 def find(id) return nil if id.to_s.empty? record = @store.get(SCOPE, record_key(id.to_s)) record && to_record(record) end |
#issue(tenant_id: nil, label: "default") ⇒ Object
Issues a token for tenant_id (nil = an OPERATOR token). -> Issue. The
token is shown exactly once; there is no get_token — lost = rotate.
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/insika/token_store.rb', line 44 def issue(tenant_id: nil, label: "default") validate_tenant_id!(tenant_id) token = SecureRandom.hex(32) hash = digest(token) id = SecureRandom.uuid @store.transaction do @store.set(SCOPE, record_key(id), { "id" => id, "token_hash" => hash, "role" => tenant_id ? "tenant" : "operator", "tenant_id" => tenant_id, "label" => label.to_s, "status" => "active", "created_at" => Time.now.utc.iso8601, "revoked_at" => nil }) @store.set(SCOPE, hash_key(hash), id) end Issue.new(id: id, token: token) end |
#resolve(token) ⇒ Object
The edge resolution: -> Record (active) | nil. A revoked token is indistinguishable from a missing one (fail-closed: the Bearer just 401s).
73 74 75 76 77 78 79 80 81 |
# File 'lib/insika/token_store.rb', line 73 def resolve(token) return nil if token.to_s.empty? id = @store.get(SCOPE, hash_key(digest(token))) return nil if id.nil? record = to_record(@store.get(SCOPE, record_key(id))) record&.active? ? record : nil end |
#revoke(id) ⇒ Object
-> bool: true only for an ACTIVE record (revoking an already-revoked/unknown
id is false — a no-op, never an error). The read-modify-write rides
@store.transaction (the repo's rule — WS1): next false, NOT return false, or the non-local return would skip the COMMIT and leak the open
transaction (the same trap as the budget ledger's mark_alert).
88 89 90 91 92 93 94 95 96 97 |
# File 'lib/insika/token_store.rb', line 88 def revoke(id) @store.transaction do record = find(id) next false unless record&.active? flipped = record.to_h.merge(status: "revoked", revoked_at: Time.now.utc.iso8601) @store.set(SCOPE, record_key(id), stringify(flipped)) true end end |
#revoke_all(tenant_id:) ⇒ Object
Revokes every ACTIVE token of a tenant; the hash-index cells stay (they resolve to a revoked record -> nil). -> count of records revoked. Does NOT touch the operator token or any other tenant.
102 103 104 105 106 107 108 |
# File 'lib/insika/token_store.rb', line 102 def revoke_all(tenant_id:) ids = active_token_ids.select do |id| record = to_record(@store.get(SCOPE, record_key(id))) record&.tenant? && record.tenant_id.to_s == tenant_id.to_s end ids.count { |id| revoke(id) } end |
#rotate(tenant_id:, label: "default", now: Time.now) ⇒ Object
Rotation: revoke the tenant's active tokens, issue a fresh one. Both in one transaction -> a crashed half-rotation never leaves the tenant with NOTHING valid. -> { revoked: n, issue: Issue }.
113 114 115 116 117 118 |
# File 'lib/insika/token_store.rb', line 113 def rotate(tenant_id:, label: "default", now: Time.now) @store.transaction do revoked = revoke_all(tenant_id: tenant_id) { revoked: revoked, issue: issue(tenant_id: tenant_id, label: label) } end end |