Module: Hecks::Projections::OIDC

Extended by:
Hecks::Projector::Target
Defined in:
lib/hecks/projections/oidc.rb

Overview

AN OIDC CLIENT/SCOPE MANIFEST derived from a domain's own IR: what an identity provider has to know about this domain before it can issue a token that means anything here.

THE ARTIFACT HALF OF SOMETHING ALREADY HALF-BUILT. spec/oidc_projection_spec.rb covers the INTEGRATION half — verified claims in, IdentityResolution.resolveAuthorization.holds_role? → a dispatch scoped by Hecks.as_caller(role:). That half enforces a role per command. It just had no way to say, up front and as data, WHICH role each command wants — every answer came from asking the live runtime one dispatch at a time.

This is that catalogue, and the two are checked against each other rather than merely coexisting: verb here is spelled exactly as Runtime::Dispatcher#dispatch takes it and exactly as Bluebook#verbs lists it, so a scope can never name a command the domain does not have (spec/projections/oidc_projection_spec.rb holds the two lists equal), and role is the same string Ports::Authorization.holds_role? compares against a real Governance::RoleAssignment.

ROLES COME FROM THE COMMANDS, NOT FROM GOVERNANCE. A command's own role "Compliance officer" is in the bluebook IR (Command#role), whereas uses_framework "Governance" is declared in the .hecksagon — which call(bluebook:, options:) cannot see at all. Reading the commands is both the only thing available here AND the more accurate source: it says what each command actually demands, not merely which role vocabulary the application happened to mount.

A command with no declared role projects "role" => nil rather than being dropped — an unguarded command is a real fact about the domain, and silently omitting it would make the manifest read as though the command did not exist.

Class Method Summary collapse

Methods included from Hecks::Projector::Target

projection_declares, projection_emits, projection_key, projection_requires, projects_as

Class Method Details

.aggregate_scopes(bluebook, aggregate) ⇒ Object



75
76
77
78
79
80
81
# File 'lib/hecks/projections/oidc.rb', line 75

def aggregate_scopes(bluebook, aggregate)
  verb_prefix  = "#{bluebook.name}::#{aggregate.hecks_name}"
  scope_prefix = "#{Naming.snake(bluebook.name)}:#{Naming.snake(aggregate.hecks_name)}"

  command_scopes(aggregate.commands, verb_prefix, scope_prefix) +
    aggregate.entities.flat_map { |entity| entity_scopes(entity, verb_prefix, scope_prefix) }
end

.call(bluebook:, options: {}) ⇒ Object

audience: overrides the domain name, for the ordinary case where the IdP's registered audience is a URL rather than a bare chapter name.



50
51
52
53
54
55
56
57
58
# File 'lib/hecks/projections/oidc.rb', line 50

def call(bluebook:, options: {})
  scopes = scopes_for(bluebook)

  {
    "audience" => (options[:audience] || bluebook.name).to_s,
    "scopes"   => scopes,
    "roles"    => scopes.map { |scope| scope["role"] }.compact.uniq.sort
  }
end

.command_scopes(commands, verb_prefix, scope_prefix) ⇒ Object

banking:account.open — the shape an OIDC scope is conventionally spelled in, and snake_cased through the SAME Naming.snake the facade uses to name a command's own door method, so a scope and the Ruby call that satisfies it cannot drift apart.



99
100
101
102
103
104
105
106
107
# File 'lib/hecks/projections/oidc.rb', line 99

def command_scopes(commands, verb_prefix, scope_prefix)
  commands.map do |command|
    {
      "scope" => "#{scope_prefix}.#{Naming.snake(command.hecks_name)}",
      "verb"  => "#{verb_prefix}.#{command.hecks_name}",
      "role"  => command.role
    }
  end
end

.entity_scopes(entity, verb_prefix, scope_prefix) ⇒ Object

S17, ADR 0026 — an entity can nest further entities (Dispatch, inside Handler), so this recurses the same way Chapter#verbs now does. The verb and scope prefixes grow in lockstep, each .-joined the same way its own kind already was.



87
88
89
90
91
92
93
# File 'lib/hecks/projections/oidc.rb', line 87

def entity_scopes(entity, verb_prefix, scope_prefix)
  verb_prefix  = "#{verb_prefix}.#{entity.hecks_name}"
  scope_prefix = "#{scope_prefix}.#{Naming.snake(entity.hecks_name)}"

  command_scopes(entity.commands, verb_prefix, scope_prefix) +
    entity.entities.flat_map { |piece| entity_scopes(piece, verb_prefix, scope_prefix) }
end

.scopes_for(bluebook) ⇒ Object

Sorted by scope, not left in declaration order — a manifest is something two versions of get diffed, and the same precedent StorageShape.project sets for its own aggregate list.

Recurses into entities, not just an aggregate's own direct commands — the exact same gap Bluebook#verbs closed for the same reason (chapter.rb's own comment): a command reached through Dispatcher#dispatch's dotted Entity.Command routing is real and callable, so a manifest that never names it can never grant a client a scope for it either.



70
71
72
73
# File 'lib/hecks/projections/oidc.rb', line 70

def scopes_for(bluebook)
  bluebook.aggregates.flat_map { |aggregate| aggregate_scopes(bluebook, aggregate) }
          .sort_by { |scope| scope["scope"] }
end