Class: Insika::TokenStore

Inherits:
Object
  • Object
show all
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

Classes: Issue, Record

Constant Summary collapse

SCOPE =
"tenant_tokens"

Instance Method Summary collapse

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_idsObject

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