open_blog 0.4.0 → 0.5.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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/app/models/open_blog/api_token.rb +26 -5
  4. data/app/models/open_blog/api_token_event.rb +9 -0
  5. data/app/views/open_blog/admin_suite/api_tokens/_banner.html.erb +5 -0
  6. data/app/views/open_blog/admin_suite/api_tokens/_error.html.erb +3 -0
  7. data/app/views/open_blog/admin_suite/api_tokens/error.html.erb +6 -0
  8. data/app/views/open_blog/admin_suite/api_tokens/index.html.erb +47 -0
  9. data/app/views/open_blog/admin_suite/api_tokens/new.html.erb +44 -0
  10. data/app/views/open_blog/admin_suite/api_tokens/revealed.html.erb +30 -0
  11. data/app/views/open_blog/admin_suite/api_tokens/show.html.erb +80 -0
  12. data/db/migrate/20261029120000_add_open_blog_api_token_management.rb +18 -0
  13. data/lib/generators/open_blog/admin_suite/admin_suite_generator.rb +3 -0
  14. data/lib/generators/open_blog/admin_suite/templates/api_tokens_initializer.rb.tt +9 -0
  15. data/lib/generators/open_blog/admin_suite/templates/open_blog.yml.tt +2 -0
  16. data/lib/open_blog/admin_suite/api_tokens_controller.rb +150 -0
  17. data/lib/open_blog/admin_suite.rb +38 -0
  18. data/lib/open_blog/api_tokens/grant.rb +12 -0
  19. data/lib/open_blog/api_tokens/result.rb +13 -0
  20. data/lib/open_blog/api_tokens/secret.rb +33 -0
  21. data/lib/open_blog/api_tokens/yaml_grants.rb +67 -0
  22. data/lib/open_blog/api_tokens.rb +146 -0
  23. data/lib/open_blog/authentication.rb +4 -1
  24. data/lib/open_blog/configuration.rb +50 -11
  25. data/lib/open_blog/doctor.rb +16 -0
  26. data/lib/open_blog/version.rb +1 -1
  27. data/lib/open_blog.rb +3 -0
  28. data/skills/open-blog-install/SKILL.md +17 -0
  29. metadata +19 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: efdb8a5a4e6810822b5821360a7dafa544d704135f98ef877c226d7ae7888117
4
- data.tar.gz: e15a793d38b597451baf0accb892cc5cb73ab4e86fa82fc90a04e6ea45807d9c
3
+ metadata.gz: 73849fe8c16692737f1e4ef73f9c2081832b804c1f07f5263c04d17fecc8340e
4
+ data.tar.gz: 55ae82fb654aedab55a31ce1f9eaa19283483cb79dbb0bc82bf32b280e98bf8f
5
5
  SHA512:
6
- metadata.gz: a6554c741ba91b63ef85d001ea36f25b62aad3b351407a6e46fff323e79204a52aa7fbedd3837c114fb96548fc3b312236e8562fbb613d03c4ac0ec504ec6a12
7
- data.tar.gz: 6cb4979aab3f7ea903759ff765c3a9843a7d917a9a1965241fcf2f9326c6dc7b338fe88e597e69248233e77d194254e7c0c2aeb71c325adeaa7840069cea55b5
6
+ metadata.gz: f58ba9004bcc2d78f56d44792203a4c94626fb9b74dbcd16936428920b67811b0f6b3667a72aa9578045e591d12f1bc0565ac8fe3e29c0ab39a0a1b58781a5ed
7
+ data.tar.gz: 0d2909e6256bc77b2c46a0c81aa3a13536b096dfbcb46f6a3c638c675e2fbd8513f16e19d55931e7da284bfec15a1acd802092cf2a1876b7a7732603fb331610
data/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-10-29
11
+
12
+ ### Added
13
+
14
+ - AdminSuite pages to issue, list, revoke, and rotate API tokens, with per-identity grants, expiry and grace policy, one-time secret reveal, and an audit trail. `open_blog:admin_suite` now writes `config/initializers/open_blog_api_tokens.rb` and `config/open_blog.yml`, and doctor reports whether token management is wired.
15
+ - Add the `AddOpenBlogApiTokenManagement` migration. It adds the token owner and successor columns and the `open_blog_api_token_events` table.
16
+
17
+ ### Changed
18
+
19
+ - A token issued in AdminSuite records its owner. The API actor for such a token is `"<name> (<identity>)"`, for example `"Deploy (admin:42)"`, so revision `actor`, publication `released_by`, approval and connection `recorded_by`, and image `uploaded_by` show that name. Tokens without an owner, such as ones from `open_blog:token`, keep the plain name.
20
+ - `ApiToken` refuses an empty or repeated scope list. `SCOPES= bin/rails open_blog:token` now fails instead of issuing a token with no scopes, and `update!` on an existing token with no scopes fails until you give it a scope.
21
+ - A token with audit events cannot be destroyed. Revoke it instead.
22
+ - Doctor's **API token management** check runs when Open Blog's AdminSuite adapter is installed and names each missing piece: no identity hook, no grant policy, no grants in `config/open_blog.yml`, or tokens inactive because `config.authenticate` is set.
23
+
24
+ ### Upgrade notes
25
+
26
+ - Run `bin/rails railties:install:migrations FROM=open_blog` and `bin/rails db:migrate`. Existing tokens and `open_blog:token` keep working before the migration runs; the AdminSuite token pages need it.
27
+ - To revoke a token that has no scopes, use the AdminSuite page or `OpenBlog::ApiToken.find(id).update_column(:revoked_at, Time.current)`, because `update!` now fails validation for that token.
28
+
10
29
  ## [0.4.0] - 2026-10-28
11
30
 
12
31
  ### Changed
@@ -3,23 +3,44 @@ require "active_support/core_ext/securerandom"
3
3
 
4
4
  module OpenBlog
5
5
  class ApiToken < ApplicationRecord
6
- SCOPES = %w[read write publish].freeze
6
+ SCOPES = ApiTokens::SCOPES
7
7
  scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
8
8
 
9
+ self.filter_attributes += %w[token_digest]
10
+
11
+ belongs_to :successor, class_name: "OpenBlog::ApiToken", optional: true
12
+ has_one :predecessor, class_name: "OpenBlog::ApiToken", foreign_key: :successor_id, inverse_of: :successor
13
+ has_many :events, class_name: "OpenBlog::ApiTokenEvent", dependent: :restrict_with_exception
14
+
9
15
  validates :name, presence: true
10
16
  validates :token_digest, presence: true, uniqueness: true, format: { with: /\A[0-9a-f]{64}\z/ }
11
17
  validates :token_prefix, length: { is: 8 }
12
18
  validate do
13
- errors.add(:scopes, "must contain only read, write or publish") unless scopes.is_a?(Array) && (scopes - SCOPES).empty?
19
+ if !scopes.is_a?(Array) || (scopes - SCOPES).any?
20
+ errors.add(:scopes, "must contain only read, write or publish")
21
+ elsif scopes.empty?
22
+ errors.add(:scopes, "must not be empty")
23
+ elsif scopes.uniq.size != scopes.size
24
+ errors.add(:scopes, "must not repeat a scope")
25
+ end
14
26
  end
15
27
 
16
- def self.generate(name:, scopes: SCOPES, expires_at: nil)
28
+ def self.generate(name:, scopes: SCOPES, expires_at: nil, creator_identity: nil)
17
29
  secret = "ob_#{SecureRandom.base58(40)}"
18
- record = create!(name: name, scopes: scopes, expires_at: expires_at,
19
- token_digest: Digest::SHA256.hexdigest(secret), token_prefix: secret[0, 8])
30
+ attributes = { name: name, scopes: scopes, expires_at: expires_at,
31
+ token_digest: Digest::SHA256.hexdigest(secret), token_prefix: secret[0, 8] }
32
+ attributes[:creator_identity] = creator_identity if creator_identity
33
+ record = create!(attributes)
20
34
  [ record, secret ]
21
35
  end
22
36
 
37
+ def status
38
+ if revoked_at then :revoked
39
+ elsif expires_at && expires_at <= Time.current then :expired
40
+ else :active
41
+ end
42
+ end
43
+
23
44
  def self.authenticate(secret)
24
45
  return unless secret.is_a?(String) && secret.match?(/\Aob_[1-9A-HJ-NP-Za-km-z]{40}\z/)
25
46
  digest = Digest::SHA256.hexdigest(secret)
@@ -0,0 +1,9 @@
1
+ module OpenBlog
2
+ class ApiTokenEvent < ApplicationRecord
3
+ ACTIONS = %w[issued rotated revoked].freeze
4
+
5
+ belongs_to :api_token
6
+ validates :action, inclusion: { in: ACTIONS }
7
+ validates :actor_identity, presence: true
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ <% if tokens_inactive? %>
2
+ <div class="mb-6 rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900" role="status">
3
+ This host uses its own API authentication hook. Tokens issued here would not work.
4
+ </div>
5
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <% if @error %>
2
+ <div class="mb-6 rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-900" role="alert"><%= @error %></div>
3
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <% content_for :title, "API tokens - Admin Suite" %>
2
+
3
+ <div class="px-4 sm:px-6 lg:px-8 max-w-3xl mx-auto">
4
+ <h1 class="text-2xl font-bold text-slate-900 mb-4">API tokens</h1>
5
+ <p class="text-slate-700"><%= @message %></p>
6
+ </div>
@@ -0,0 +1,47 @@
1
+ <% content_for :title, "API tokens - Admin Suite" %>
2
+
3
+ <div class="px-4 sm:px-6 lg:px-8">
4
+ <div class="mb-6 flex items-center justify-between">
5
+ <h1 class="text-2xl font-bold text-slate-900">API tokens</h1>
6
+ <% if grant.manage_own && !tokens_inactive? %>
7
+ <%= link_to "New token", new_open_blog_api_token_path, class: "px-4 py-2 rounded-lg bg-slate-900 text-white text-sm font-medium" %>
8
+ <% end %>
9
+ </div>
10
+
11
+ <%= render "banner" %>
12
+
13
+ <% if @tokens.empty? %>
14
+ <p class="text-slate-600">No API tokens yet.</p>
15
+ <% else %>
16
+ <div class="bg-white rounded-xl border border-slate-200 overflow-x-auto">
17
+ <table class="min-w-full text-sm">
18
+ <thead class="bg-slate-50 text-left text-slate-600">
19
+ <tr>
20
+ <th class="px-4 py-3">Name</th>
21
+ <th class="px-4 py-3">Prefix</th>
22
+ <th class="px-4 py-3">Scopes</th>
23
+ <th class="px-4 py-3">Owner</th>
24
+ <th class="px-4 py-3">Status</th>
25
+ <th class="px-4 py-3">Created</th>
26
+ <th class="px-4 py-3">Last used</th>
27
+ <th class="px-4 py-3">Expires</th>
28
+ </tr>
29
+ </thead>
30
+ <tbody class="divide-y divide-slate-100">
31
+ <% @tokens.each do |token| %>
32
+ <tr>
33
+ <td class="px-4 py-3"><%= link_to token.name, open_blog_api_token_path(token) %></td>
34
+ <td class="px-4 py-3 font-mono"><%= token.token_prefix %></td>
35
+ <td class="px-4 py-3"><%= token.scopes.join(", ") %></td>
36
+ <td class="px-4 py-3"><%= token.creator_identity || "No owner" %></td>
37
+ <td class="px-4 py-3"><%= token.status.to_s.humanize %></td>
38
+ <td class="px-4 py-3"><%= token_time(token.created_at) %></td>
39
+ <td class="px-4 py-3"><%= token.last_used_at ? token_time(token.last_used_at) : "Never" %></td>
40
+ <td class="px-4 py-3"><%= token.expires_at ? token_time(token.expires_at) : "Never" %></td>
41
+ </tr>
42
+ <% end %>
43
+ </tbody>
44
+ </table>
45
+ </div>
46
+ <% end %>
47
+ </div>
@@ -0,0 +1,44 @@
1
+ <% content_for :title, "New API token - Admin Suite" %>
2
+ <% content_for :head do %>
3
+ <%= tag.style(<<~CSS.html_safe, nonce: content_security_policy_nonce) %>
4
+ form:has(input[name="scopes[]"][value="read"]:checked) .open-blog-read-warning { display: none; }
5
+ CSS
6
+ <% end %>
7
+
8
+ <div class="px-4 sm:px-6 lg:px-8 max-w-3xl mx-auto">
9
+ <nav class="mb-2 text-sm text-slate-500"><%= link_to "API tokens", open_blog_api_tokens_path %></nav>
10
+ <h1 class="text-2xl font-bold text-slate-900 mb-6">New API token</h1>
11
+
12
+ <%= render "banner" %>
13
+ <%= render "error" %>
14
+
15
+ <div class="bg-white rounded-xl border border-slate-200 p-6">
16
+ <%= form_with url: open_blog_api_tokens_path, method: :post, data: { turbo: false }, class: "space-y-6" do |form| %>
17
+ <div>
18
+ <%= form.label :name, "Name", class: "block text-sm font-medium text-slate-700" %>
19
+ <%= form.text_field :name, value: params[:name], required: true, maxlength: OpenBlog::ApiTokens::NAME_LENGTH.max, class: "form-input mt-1 w-full" %>
20
+ </div>
21
+
22
+ <fieldset>
23
+ <legend class="text-sm font-medium text-slate-700">Scopes</legend>
24
+ <% grant.scopes.each do |scope| %>
25
+ <label class="mt-2 flex items-center gap-2 text-sm">
26
+ <%= check_box_tag "scopes[]", scope, @scopes.include?(scope), id: "scope_#{scope}" %>
27
+ <%= scope %>
28
+ </label>
29
+ <% end %>
30
+ <p class="open-blog-read-warning mt-2 text-sm text-amber-800">Without read, this token cannot list or fetch posts.</p>
31
+ </fieldset>
32
+
33
+ <div>
34
+ <%= form.label :expires_at, "Expires at (#{Time.zone.name})", class: "block text-sm font-medium text-slate-700" %>
35
+ <%= form.datetime_local_field :expires_at, value: @expires_at, required: !no_expiry_allowed?, class: "form-input mt-1" %>
36
+ <% if no_expiry_allowed? %>
37
+ <p class="mt-1 text-sm text-slate-500">Leave blank for no expiry.</p>
38
+ <% end %>
39
+ </div>
40
+
41
+ <%= form.submit "Create token", disabled: tokens_inactive?, class: "px-4 py-2 rounded-lg bg-slate-900 text-white text-sm font-medium" %>
42
+ <% end %>
43
+ </div>
44
+ </div>
@@ -0,0 +1,30 @@
1
+ <% content_for :title, "API token created - Admin Suite" %>
2
+ <% content_for :head do %>
3
+ <meta name="turbo-cache-control" content="no-cache">
4
+ <% end %>
5
+
6
+ <div class="px-4 sm:px-6 lg:px-8 max-w-3xl mx-auto">
7
+ <h1 class="text-2xl font-bold text-slate-900 mb-6"><%= @token.name %></h1>
8
+
9
+ <div class="bg-white rounded-xl border border-amber-300 p-6 space-y-4">
10
+ <p class="text-sm font-medium text-amber-900">Copy this token into your password manager now. It will not be shown again.</p>
11
+ <div class="flex gap-2">
12
+ <input id="open-blog-api-token-secret" type="text" readonly value="<%= @secret.reveal %>" autocomplete="off" spellcheck="false" class="form-input flex-1 font-mono" aria-label="API token">
13
+ <button type="button" id="open-blog-api-token-copy" class="px-4 py-2 rounded-lg bg-slate-900 text-white text-sm font-medium">Copy</button>
14
+ </div>
15
+ <%= javascript_tag nonce: true do %>
16
+ document.getElementById("open-blog-api-token-copy").addEventListener("click", function () {
17
+ var field = document.getElementById("open-blog-api-token-secret");
18
+ field.select();
19
+ if (navigator.clipboard) navigator.clipboard.writeText(field.value);
20
+ });
21
+ <% end %>
22
+ <p class="text-sm text-slate-600">Scopes: <%= @token.scopes.join(", ") %>. Expires: <%= @token.expires_at ? token_time(@token.expires_at) : "never" %>.</p>
23
+ <% if @predecessor %>
24
+ <p class="text-sm font-medium text-slate-900">
25
+ <%= @predecessor.revoked_at ? "The previous token stopped working now." : "The previous token stops working at #{token_time(@predecessor.expires_at)}." %>
26
+ </p>
27
+ <% end %>
28
+ <p class="text-sm"><%= link_to "Back to API tokens", open_blog_api_tokens_path %></p>
29
+ </div>
30
+ </div>
@@ -0,0 +1,80 @@
1
+ <% content_for :title, "API token - Admin Suite" %>
2
+
3
+ <div class="px-4 sm:px-6 lg:px-8 max-w-4xl mx-auto">
4
+ <nav class="mb-2 text-sm text-slate-500"><%= link_to "API tokens", open_blog_api_tokens_path %></nav>
5
+ <h1 class="text-2xl font-bold text-slate-900 mb-6"><%= @token.name %></h1>
6
+
7
+ <%= render "error" %>
8
+
9
+ <div class="bg-white rounded-xl border border-slate-200 p-6 mb-6">
10
+ <dl class="grid grid-cols-2 gap-4 text-sm">
11
+ <dt class="text-slate-500">Prefix</dt><dd class="font-mono"><%= @token.token_prefix %></dd>
12
+ <dt class="text-slate-500">Scopes</dt><dd><%= @token.scopes.join(", ") %></dd>
13
+ <dt class="text-slate-500">Owner</dt><dd><%= @token.creator_identity || "No owner" %></dd>
14
+ <dt class="text-slate-500">Status</dt><dd><%= @token.status.to_s.humanize %></dd>
15
+ <dt class="text-slate-500">Created</dt><dd><%= token_time(@token.created_at) %></dd>
16
+ <dt class="text-slate-500">Last used</dt><dd><%= @token.last_used_at ? token_time(@token.last_used_at) : "Never" %></dd>
17
+ <dt class="text-slate-500">Expires</dt><dd><%= @token.expires_at ? token_time(@token.expires_at) : "Never" %></dd>
18
+ <% if @token.revoked_at %>
19
+ <dt class="text-slate-500">Revoked</dt><dd><%= token_time(@token.revoked_at) %></dd>
20
+ <% end %>
21
+ <% if @token.successor_id %>
22
+ <dt class="text-slate-500">Replaced by</dt><dd><%= link_to "Token #{@token.successor_id}", open_blog_api_token_path(@token.successor_id) %></dd>
23
+ <% end %>
24
+ </dl>
25
+ </div>
26
+
27
+ <% if @token.status == :active %>
28
+ <div class="grid gap-6 md:grid-cols-2 mb-6">
29
+ <% if @token.successor_id.nil? && @token.creator_identity == @identity && grant.manage_own && !tokens_inactive? %>
30
+ <div class="bg-white rounded-xl border border-slate-200 p-6">
31
+ <h2 class="text-lg font-semibold text-slate-900 mb-2">Rotate</h2>
32
+ <p class="text-sm text-slate-600 mb-4">Rotation issues a replacement with the same name and scopes. Without grace, this token stops working at once.</p>
33
+ <%= form_with url: rotate_open_blog_api_token_path(@token), method: :post, data: { turbo: false }, class: "space-y-4" do |form| %>
34
+ <div>
35
+ <%= form.label :grace, "Grace period", class: "block text-sm font-medium text-slate-700" %>
36
+ <%= form.select :grace, grace_options, {}, class: "form-select mt-1" %>
37
+ </div>
38
+ <label class="flex items-center gap-2 text-sm">
39
+ <%= check_box_tag :confirm, "1", false, id: "rotate_confirm" %>
40
+ I understand clients using this token must switch to the replacement.
41
+ </label>
42
+ <%= form.submit "Rotate token", class: "px-4 py-2 rounded-lg bg-slate-900 text-white text-sm font-medium" %>
43
+ <% end %>
44
+ </div>
45
+ <% end %>
46
+ <% if @token.revoked_at.nil? %>
47
+ <div class="bg-white rounded-xl border border-red-200 p-6">
48
+ <h2 class="text-lg font-semibold text-slate-900 mb-2">Revoke</h2>
49
+ <p class="text-sm text-slate-600 mb-4">Revoking stops this token at once. This cannot be undone.</p>
50
+ <%= form_with url: revoke_open_blog_api_token_path(@token), method: :post, data: { turbo: false }, class: "space-y-4" do |form| %>
51
+ <label class="flex items-center gap-2 text-sm">
52
+ <%= check_box_tag :confirm, "1", false, id: "revoke_confirm" %>
53
+ I understand clients using this token will stop working.
54
+ </label>
55
+ <%= form.submit "Revoke token", class: "px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium" %>
56
+ <% end %>
57
+ </div>
58
+ <% end %>
59
+ </div>
60
+ <% end %>
61
+
62
+ <div class="bg-white rounded-xl border border-slate-200 p-6">
63
+ <h2 class="text-lg font-semibold text-slate-900 mb-4">Events</h2>
64
+ <table class="min-w-full text-sm">
65
+ <thead class="text-left text-slate-600">
66
+ <tr><th class="py-2 pr-4">Action</th><th class="py-2 pr-4">Actor</th><th class="py-2 pr-4">Time</th><th class="py-2">Details</th></tr>
67
+ </thead>
68
+ <tbody class="divide-y divide-slate-100">
69
+ <% @token.events.order(:created_at, :id).each do |event| %>
70
+ <tr>
71
+ <td class="py-2 pr-4"><%= event.action %></td>
72
+ <td class="py-2 pr-4"><%= event.actor_identity %></td>
73
+ <td class="py-2 pr-4"><%= token_time(event.created_at) %></td>
74
+ <td class="py-2 font-mono text-xs"><%= (event.details || {}).map { |key, value| "#{key}: #{Array(value).join(", ")}" }.join("; ") %></td>
75
+ </tr>
76
+ <% end %>
77
+ </tbody>
78
+ </table>
79
+ </div>
80
+ </div>
@@ -0,0 +1,18 @@
1
+ class AddOpenBlogApiTokenManagement < ActiveRecord::Migration[8.0]
2
+ def change
3
+ key_type = connection.columns(:open_blog_api_tokens).find { |column| column.name == "id" }.type == :uuid ? :uuid : :bigint
4
+
5
+ change_table :open_blog_api_tokens do |t|
6
+ t.string :creator_identity, index: true
7
+ t.references :successor, type: key_type, index: { unique: true }, foreign_key: { to_table: :open_blog_api_tokens }
8
+ end
9
+
10
+ create_table :open_blog_api_token_events do |t|
11
+ t.references :api_token, type: key_type, null: false, index: true, foreign_key: { to_table: :open_blog_api_tokens }
12
+ t.string :actor_identity, null: false
13
+ t.string :action, null: false
14
+ t.public_send(connection.adapter_name == "PostgreSQL" ? :jsonb : :json, :details, null: false, default: {})
15
+ t.datetime :created_at, null: false
16
+ end
17
+ end
18
+ end
@@ -20,7 +20,10 @@ module OpenBlog
20
20
  end
21
21
  template "blog_portal.rb.tt", "#{@admin_dir}/portals/open_blog_portal.rb", **copy_options
22
22
  template "initializer.rb.tt", "config/initializers/open_blog_admin_suite.rb", **copy_options
23
+ template "api_tokens_initializer.rb.tt", "config/initializers/open_blog_api_tokens.rb", **copy_options
24
+ template "open_blog.yml.tt", "config/open_blog.yml", **copy_options
23
25
  say "Admin definitions written. Install AdminSuite 0.6.1 or later to activate them; restart the host after adding definitions."
26
+ say "API tokens stay disabled until config.api_tokens.identity returns a trusted admin identity and config/open_blog.yml grants it access."
24
27
  end
25
28
  end
26
29
  end
@@ -0,0 +1,9 @@
1
+ OpenBlog.configure do |config|
2
+ config.api_tokens.policy = OpenBlog::ApiTokens::YamlGrants.load(Rails.root.join("config/open_blog.yml"))
3
+ # Bind to the admin surface identity, never an application login fallback.
4
+ # config.api_tokens.identity = ->(controller) { admin = controller.send(:current_admin); "admin:#{admin.uid}" if admin }
5
+ config.api_tokens.default_lifetime = 90.days
6
+ config.api_tokens.require_expiry = false
7
+ config.api_tokens.max_lifetime = nil
8
+ config.api_tokens.max_grace = 24.hours
9
+ end
@@ -0,0 +1,2 @@
1
+ api_tokens:
2
+ grants: {}
@@ -0,0 +1,150 @@
1
+ module OpenBlog
2
+ module AdminSuite
3
+ class ApiTokensController < ::AdminSuite::ApplicationController
4
+ include ::AdminSuite::Engine.routes.url_helpers
5
+
6
+ GRACES = { "none" => nil, "3600" => 1.hour, "86400" => 24.hours }.freeze
7
+ ERRORS = {
8
+ invalid: "Check the name and choose at least one scope.",
9
+ scope_not_allowed: "One of those scopes is not allowed for you.",
10
+ expiry_invalid: "Choose an expiry the token policy allows.",
11
+ grace_invalid: "Choose a grace period from the list.",
12
+ not_rotatable: "Only an active token with an owner can be rotated.",
13
+ already_rotated: "This token was already rotated.",
14
+ tokens_inactive: "This host uses its own API authentication hook. Tokens issued here would not work.",
15
+ unauthorized: "You cannot do that with this token."
16
+ }.freeze
17
+
18
+ protect_from_forgery with: :exception, prepend: true
19
+ before_action :require_grant
20
+
21
+ helper_method :grant, :tokens_inactive?, :grace_options, :no_expiry_allowed?, :token_time
22
+
23
+ def index
24
+ @tokens = ApiTokens.list(identity: @identity).tokens
25
+ end
26
+
27
+ def new
28
+ return forbidden(ERRORS[:unauthorized]) unless grant.manage_own
29
+ @scopes = grant.scopes.include?("read") ? %w[read] : []
30
+ @expires_at = ApiTokens.default_expires_at.strftime("%Y-%m-%dT%H:%M")
31
+ end
32
+
33
+ def create
34
+ @scopes = params[:scopes].is_a?(Array) ? params[:scopes].map(&:to_s) : []
35
+ @expires_at = params[:expires_at].is_a?(String) ? params[:expires_at] : ""
36
+ expires_at = parse_expiry(@expires_at)
37
+ return render_new(:expiry_invalid) if expires_at == :invalid
38
+ name = params[:name].is_a?(String) ? params[:name] : ""
39
+ result = ApiTokens.issue(identity: @identity, name: name, scopes: @scopes, expires_at: expires_at)
40
+ return reveal(result) if result.success?
41
+ return forbidden(ERRORS[:unauthorized]) if result.error == :unauthorized
42
+ render_new(result.error)
43
+ end
44
+
45
+ def show
46
+ result = ApiTokens.find(identity: @identity, id: params[:id])
47
+ return failed(result) unless result.success?
48
+ @token = result.token
49
+ end
50
+
51
+ def revoke
52
+ return unless confirmed?
53
+ result = ApiTokens.revoke(identity: @identity, id: params[:id])
54
+ return failed(result) unless result.success?
55
+ redirect_to open_blog_api_token_path(result.token), notice: "Token revoked."
56
+ end
57
+
58
+ def rotate
59
+ return unless confirmed?
60
+ return render_show(ERRORS[:grace_invalid]) unless grace_options.value?(params[:grace].to_s)
61
+ result = ApiTokens.rotate(identity: @identity, id: params[:id], grace: GRACES[params[:grace].to_s])
62
+ return failed(result) unless result.success?
63
+ @predecessor = result.token.predecessor
64
+ reveal(result)
65
+ end
66
+
67
+ private
68
+
69
+ def grant
70
+ @grant
71
+ end
72
+
73
+ def require_grant
74
+ @identity = OpenBlog.config.api_tokens.identity&.call(self)
75
+ return forbidden("No trusted identity is configured for API token management.") if @identity.nil?
76
+ @grant = ApiTokens.permissions(identity: @identity)
77
+ forbidden("No token-management grant for this identity.") unless @grant && (@grant.manage_own || @grant.manage_all)
78
+ end
79
+
80
+ def token_time(time)
81
+ "#{time.in_time_zone.strftime('%Y-%m-%d %H:%M:%S')} (#{Time.zone.name})"
82
+ end
83
+
84
+ def tokens_inactive?
85
+ OpenBlog.config.authenticate ? true : false
86
+ end
87
+
88
+ def no_expiry_allowed?
89
+ settings = OpenBlog.config.api_tokens
90
+ !settings.require_expiry && settings.max_lifetime.nil?
91
+ end
92
+
93
+ def grace_options
94
+ max = OpenBlog.config.api_tokens.max_grace.to_f
95
+ { "No grace (revoke now)" => "none", "1 hour" => "3600", "24 hours" => "86400" }
96
+ .select { |_label, value| GRACES[value].nil? || GRACES[value].to_f <= max }
97
+ end
98
+
99
+ def parse_expiry(value)
100
+ return if value.strip.empty?
101
+ Time.zone.parse(value) || :invalid
102
+ rescue ArgumentError
103
+ :invalid
104
+ end
105
+
106
+ def confirmed?
107
+ return true if params[:confirm] == "1"
108
+ render_show("Tick the confirmation box to continue.")
109
+ false
110
+ end
111
+
112
+ def reveal(result)
113
+ no_store
114
+ @token = result.token
115
+ @secret = result.secret
116
+ render :revealed, status: :ok
117
+ end
118
+
119
+ def render_new(error)
120
+ @error = ERRORS.fetch(error, ERRORS[:invalid])
121
+ render :new, status: :unprocessable_content
122
+ end
123
+
124
+ def render_show(message)
125
+ @token ||= ApiTokens.find(identity: @identity, id: params[:id]).token
126
+ return not_found unless @token
127
+ @error = message
128
+ render :show, status: :unprocessable_content
129
+ end
130
+
131
+ def failed(result)
132
+ case result.error
133
+ when :not_found then not_found
134
+ when :unauthorized then forbidden(ERRORS[:unauthorized])
135
+ else render_show(ERRORS.fetch(result.error, ERRORS[:invalid]))
136
+ end
137
+ end
138
+
139
+ def forbidden(message)
140
+ @message = message
141
+ render :error, status: :forbidden
142
+ end
143
+
144
+ def not_found
145
+ @message = "No such token."
146
+ render :error, status: :not_found
147
+ end
148
+ end
149
+ end
150
+ end
@@ -38,19 +38,57 @@ module OpenBlog
38
38
  end
39
39
  end
40
40
 
41
+ module Navigation
42
+ private
43
+
44
+ def build_navigation_items
45
+ navigation = super
46
+ portal = navigation[:open_blog] ||= { label: "Open Blog", order: 100, sections: {} }
47
+ portal[:sections] ||= {}
48
+ section = portal[:sections][:access] ||= { label: "Access", order: 900, items: [] }
49
+ section[:items] << { label: "API tokens", path: open_blog_api_tokens_path, icon: "key", order: 900 }
50
+ navigation
51
+ end
52
+ end
53
+
41
54
  def self.install!
42
55
  return if @installed
43
56
  ::AdminSuite::UI::FieldRendererRegistry.register(:open_blog_faq) do |view, form, field, resource, field_class|
44
57
  render_faqs(view, form, field, resource, field_class)
45
58
  end
59
+ install_routes!
46
60
  Rails.application.reloader.to_prepare { ::OpenBlog::AdminSuite.install_controller! }
47
61
  install_controller!
48
62
  @installed = true
49
63
  end
50
64
 
65
+ # The prepended block runs at the start of every draw, so reloads redraw it once
66
+ # instead of stacking copies. Hosts that already drew routes need one redraw.
67
+ def self.install_routes!
68
+ ::AdminSuite::Engine.routes.prepend { ::OpenBlog::AdminSuite.draw_routes(self) }
69
+ Rails.application.reload_routes! if Rails.application.routes_reloader.loaded
70
+ end
71
+
72
+ def self.draw_routes(mapper)
73
+ controller = "/open_blog/admin_suite/api_tokens"
74
+ mapper.scope "open_blog/api_tokens", controller: controller do
75
+ mapper.get "/", action: :index, as: :open_blog_api_tokens
76
+ mapper.post "/", action: :create
77
+ mapper.get "/new", action: :new, as: :new_open_blog_api_token
78
+ mapper.get "/:id", action: :show, as: :open_blog_api_token
79
+ mapper.post "/:id/revoke", action: :revoke, as: :revoke_open_blog_api_token
80
+ mapper.post "/:id/rotate", action: :rotate, as: :rotate_open_blog_api_token
81
+ end
82
+ end
83
+
51
84
  def self.install_controller!
52
85
  controller = ::AdminSuite::ResourcesController
53
86
  controller.prepend(ResourceParameters) unless controller.ancestors.include?(ResourceParameters)
87
+ base = ::AdminSuite::ApplicationController
88
+ base.prepend(Navigation) unless base.ancestors.include?(Navigation)
89
+ return if const_defined?(:ApiTokensController, false) && ApiTokensController.superclass.equal?(base)
90
+ remove_const(:ApiTokensController) if const_defined?(:ApiTokensController, false)
91
+ load File.expand_path("admin_suite/api_tokens_controller.rb", __dir__)
54
92
  end
55
93
 
56
94
  def self.render_faqs(view, form, field, resource, field_class)
@@ -0,0 +1,12 @@
1
+ module OpenBlog
2
+ module ApiTokens
3
+ SCOPES = %w[read write publish].freeze
4
+ IDENTITY = /\A[a-z0-9_]+:\S{1,200}\z/
5
+
6
+ Grant = Data.define(:manage_own, :manage_all, :scopes) do
7
+ def initialize(manage_own:, manage_all:, scopes:)
8
+ super(manage_own:, manage_all:, scopes: scopes.map(&:to_s).freeze)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ module OpenBlog
2
+ module ApiTokens
3
+ Result = Data.define(:token, :tokens, :secret, :error, :details) do
4
+ def initialize(token: nil, tokens: [], secret: nil, error: nil, details: [])
5
+ super
6
+ end
7
+
8
+ def success?
9
+ error.nil?
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,33 @@
1
+ require "json"
2
+
3
+ module OpenBlog
4
+ module ApiTokens
5
+ class Secret
6
+ FILTERED = "[FILTERED]".freeze
7
+
8
+ def initialize(value)
9
+ @value = value.dup.freeze
10
+ end
11
+
12
+ def reveal
13
+ @value
14
+ end
15
+
16
+ def to_s
17
+ FILTERED
18
+ end
19
+
20
+ def inspect
21
+ FILTERED
22
+ end
23
+
24
+ def as_json(*)
25
+ FILTERED
26
+ end
27
+
28
+ def to_json(*)
29
+ FILTERED.to_json
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,67 @@
1
+ require "yaml"
2
+ require "open_blog/configuration_error"
3
+ require "open_blog/api_tokens/grant"
4
+
5
+ module OpenBlog
6
+ module ApiTokens
7
+ class YamlGrants
8
+ MANAGE_LEVELS = %w[own all].freeze
9
+
10
+ def self.load(path)
11
+ return new({}) unless File.exist?(path)
12
+
13
+ document = YAML.safe_load_file(path)
14
+ new(parse(document, path))
15
+ rescue Psych::Exception => error
16
+ raise ConfigurationError, "OpenBlog api_tokens grants #{path} is not valid YAML: #{error.message}"
17
+ end
18
+
19
+ def self.parse(document, path)
20
+ return {} if document.nil?
21
+ fail_with(path, "must be a mapping") unless document.is_a?(Hash)
22
+ unknown = document.keys - [ "api_tokens" ]
23
+ fail_with(path, "has unknown top-level keys: #{unknown.join(', ')}") if unknown.any?
24
+ section = document["api_tokens"]
25
+ return {} if section.nil?
26
+ fail_with(path, "api_tokens must be a mapping") unless section.is_a?(Hash)
27
+ unknown = section.keys - [ "grants" ]
28
+ fail_with(path, "api_tokens has unknown keys: #{unknown.join(', ')}") if unknown.any?
29
+ grants = section["grants"]
30
+ return {} if grants.nil?
31
+ fail_with(path, "grants must be a mapping") unless grants.is_a?(Hash)
32
+ grants.to_h { |identity, grant| [ identity, build_grant(identity, grant, path) ] }
33
+ end
34
+ private_class_method :parse
35
+
36
+ def self.build_grant(identity, grant, path)
37
+ fail_with(path, "grant key #{identity.inspect} must match #{IDENTITY.source}") unless identity.is_a?(String) && identity.match?(IDENTITY)
38
+ fail_with(path, "grant #{identity} must be a mapping") unless grant.is_a?(Hash)
39
+ unknown = grant.keys - %w[manage scopes]
40
+ fail_with(path, "grant #{identity} has unknown keys: #{unknown.join(', ')}") if unknown.any?
41
+ manage = Array(grant["manage"]).map(&:to_s)
42
+ fail_with(path, "grant #{identity} manage must be own or all") unless (manage - MANAGE_LEVELS).empty?
43
+ scopes = Array(grant["scopes"]).map(&:to_s)
44
+ fail_with(path, "grant #{identity} scopes must be within #{SCOPES.join(', ')}") unless (scopes - SCOPES).empty?
45
+ Grant.new(manage_own: manage.include?("own"), manage_all: manage.include?("all"), scopes: scopes.uniq)
46
+ end
47
+ private_class_method :build_grant
48
+
49
+ def self.fail_with(path, message)
50
+ raise ConfigurationError, "OpenBlog api_tokens grants #{path} #{message}"
51
+ end
52
+ private_class_method :fail_with
53
+
54
+ def initialize(grants)
55
+ @grants = grants.freeze
56
+ end
57
+
58
+ def grants_for(identity)
59
+ @grants[identity]
60
+ end
61
+
62
+ def empty?
63
+ @grants.empty?
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,146 @@
1
+ require_relative "api_tokens/grant"
2
+ require_relative "api_tokens/result"
3
+ require_relative "api_tokens/secret"
4
+
5
+ module OpenBlog
6
+ module ApiTokens
7
+ NAME_LENGTH = 1..100
8
+
9
+ class << self
10
+ def permissions(identity:)
11
+ return unless identity.is_a?(String) && identity.match?(IDENTITY)
12
+ grant = settings.policy&.grants_for(identity)
13
+ grant if grant.is_a?(Grant)
14
+ end
15
+
16
+ def default_expires_at(now: Time.current)
17
+ [ now + settings.default_lifetime, settings.max_lifetime && now + settings.max_lifetime ].compact.min
18
+ end
19
+
20
+ def list(identity:)
21
+ authorize(identity) do |grant|
22
+ Result.new(tokens: visible(identity, grant).order(created_at: :desc, id: :desc).to_a)
23
+ end
24
+ end
25
+
26
+ def find(identity:, id:)
27
+ authorize(identity) do |grant|
28
+ token = visible(identity, grant).find_by(id: id)
29
+ token ? Result.new(token: token) : failure(:not_found)
30
+ end
31
+ end
32
+
33
+ def issue(identity:, name:, scopes:, expires_at:, now: Time.current)
34
+ authorize(identity) do |grant|
35
+ next failure(:unauthorized) unless grant.manage_own
36
+ next failure(:tokens_inactive) if OpenBlog.config.authenticate
37
+ name = name.strip if name.is_a?(String)
38
+ scopes = scopes.map(&:to_s) if scopes.is_a?(Array)
39
+ invalid = []
40
+ invalid << "name" unless name.is_a?(String) && NAME_LENGTH.cover?(name.length)
41
+ invalid << "scopes" unless valid_scopes?(scopes)
42
+ next failure(:invalid, invalid) if invalid.any?
43
+ next failure(:scope_not_allowed) unless (scopes - grant.scopes).empty?
44
+ next failure(:expiry_invalid) unless valid_expiry?(expires_at, now)
45
+ ApiToken.transaction { create_token(identity, name, scopes, expires_at) }
46
+ end
47
+ end
48
+
49
+ def revoke(identity:, id:, now: Time.current)
50
+ authorize(identity) do |grant|
51
+ token = visible(identity, grant).find_by(id: id)
52
+ next failure(:not_found) unless token
53
+ ApiToken.transaction do
54
+ if ApiToken.where(id: token.id, revoked_at: nil).update_all(revoked_at: now, updated_at: now) == 1
55
+ record_event(token, identity, "revoked", {})
56
+ end
57
+ Result.new(token: token.reload)
58
+ end
59
+ end
60
+ end
61
+
62
+ def rotate(identity:, id:, grace: nil, now: Time.current)
63
+ authorize(identity) do |grant|
64
+ next failure(:tokens_inactive) if OpenBlog.config.authenticate
65
+ token = visible(identity, grant).find_by(id: id)
66
+ next failure(:not_found) unless token
67
+ next failure(:not_rotatable) if token.creator_identity.nil?
68
+ next failure(:unauthorized) unless token.creator_identity == identity && grant.manage_own
69
+ next failure(:grace_invalid) unless grace.nil? || valid_grace?(grace)
70
+ ApiToken.transaction(requires_new: true) do
71
+ rotate_locked(ApiToken.lock.find(token.id), identity, grant, grace, now)
72
+ end || failure(:already_rotated)
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def settings
79
+ OpenBlog.config.api_tokens
80
+ end
81
+
82
+ def authorize(identity)
83
+ grant = permissions(identity: identity)
84
+ return failure(:unauthorized) unless grant && (grant.manage_own || grant.manage_all)
85
+ yield grant
86
+ end
87
+
88
+ def visible(identity, grant)
89
+ grant.manage_all ? ApiToken.all : ApiToken.where(creator_identity: identity)
90
+ end
91
+
92
+ def failure(error, details = [])
93
+ Result.new(error: error, details: details)
94
+ end
95
+
96
+ def valid_scopes?(scopes)
97
+ scopes.is_a?(Array) && scopes.any? && scopes.uniq.size == scopes.size && (scopes - SCOPES).empty?
98
+ end
99
+
100
+ def valid_expiry?(expires_at, now)
101
+ max = settings.max_lifetime
102
+ return !settings.require_expiry && max.nil? if expires_at.nil?
103
+ return false unless expires_at.is_a?(Time) || expires_at.is_a?(DateTime)
104
+ expires_at > now && (max.nil? || expires_at <= now + max)
105
+ end
106
+
107
+ def valid_grace?(grace)
108
+ (grace.is_a?(Numeric) || grace.is_a?(ActiveSupport::Duration)) && grace.to_f.finite? &&
109
+ grace.to_f.positive? && grace.to_f <= settings.max_grace.to_f
110
+ end
111
+
112
+ def create_token(identity, name, scopes, expires_at, **details)
113
+ token, secret = ApiToken.generate(name: name, scopes: scopes, expires_at: expires_at, creator_identity: identity)
114
+ record_event(token, identity, "issued", { scopes: scopes, expires_at: expires_at&.iso8601, **details })
115
+ Result.new(token: token, secret: Secret.new(secret))
116
+ end
117
+
118
+ # sqlite ignores FOR UPDATE, so the guarded update_all is what keeps one successor
119
+ # per token. Losing that race raises Rollback, which needs the requires_new savepoint.
120
+ def rotate_locked(old, identity, grant, grace, now)
121
+ return failure(:already_rotated) if old.successor_id
122
+ return failure(:not_rotatable) unless old.revoked_at.nil? && (old.expires_at.nil? || old.expires_at > now)
123
+ return failure(:scope_not_allowed) unless (old.scopes - grant.scopes).empty?
124
+
125
+ cutoff = grace ? [ old.expires_at, now + grace ].compact.min : now
126
+ replacement = create_token(identity, old.name, old.scopes, default_expires_at(now: now), predecessor_id: old.id)
127
+ changes = grace ? { expires_at: cutoff } : { revoked_at: now }
128
+ claimed = begin
129
+ ApiToken.where(id: old.id, successor_id: nil, revoked_at: nil)
130
+ .update_all(changes.merge(successor_id: replacement.token.id, updated_at: now))
131
+ rescue ActiveRecord::RecordNotUnique
132
+ 0
133
+ end
134
+ raise ActiveRecord::Rollback unless claimed == 1
135
+
136
+ record_event(old, identity, "rotated", { successor_id: replacement.token.id, old_expires_at: old.expires_at&.iso8601,
137
+ new_cutoff: cutoff.iso8601, revoked: grace.nil? })
138
+ replacement
139
+ end
140
+
141
+ def record_event(token, identity, action, details)
142
+ ApiTokenEvent.create!(api_token: token, actor_identity: identity, action: action, details: details)
143
+ end
144
+ end
145
+ end
146
+ end
@@ -12,7 +12,10 @@ module OpenBlog
12
12
  match = /\ABearer (ob_[1-9A-HJ-NP-Za-km-z]{40})\z/.match(request.authorization.to_s)
13
13
  return unless match
14
14
  token = ApiToken.authenticate(match[1])
15
- Actor.new(name: token.name, scopes: token.scopes, id: "token:#{token.id}") if token
15
+ return unless token
16
+ owner = token.has_attribute?(:creator_identity) && token.creator_identity.presence
17
+ name = owner ? "#{token.name} (#{owner})" : token.name
18
+ Actor.new(name: name, scopes: token.scopes, id: "token:#{token.id}")
16
19
  end
17
20
  end
18
21
  end
@@ -18,6 +18,23 @@ module OpenBlog
18
18
  end
19
19
  end
20
20
 
21
+ class ApiTokens
22
+ attr_reader :identity
23
+ attr_accessor :policy, :default_lifetime, :require_expiry, :max_lifetime, :max_grace
24
+
25
+ def initialize
26
+ @default_lifetime = 90.days
27
+ @require_expiry = false
28
+ @max_lifetime = nil
29
+ @max_grace = 24.hours
30
+ end
31
+
32
+ def identity=(hook)
33
+ Configuration.validate_hook!(:identity, hook, 1)
34
+ @identity = hook
35
+ end
36
+ end
37
+
21
38
  THEMES = (Themes.names + [ :none ]).freeze
22
39
  THEME_MODES = %i[light dark].freeze
23
40
  THEME_COLOR_KEYS = Themes::COLOR_TOKENS.to_h { |token| [ token.tr("-", "_").to_sym, token ] }.freeze
@@ -31,7 +48,7 @@ module OpenBlog
31
48
  :page_views, :page_view_bot_pattern, :page_view_retention_days, :popular_posts,
32
49
  :preview_expires_in, :storage_service, :image_delivery, :max_image_bytes,
33
50
  :image_content_types, :image_fetch_policy, :api_rate_limit, :search_rate_limit
34
- attr_reader :mcp, :authenticate, :before_publish
51
+ attr_reader :mcp, :api_tokens, :authenticate, :before_publish
35
52
  attr_writer :title_suffix, :rate_limit_store
36
53
 
37
54
  def initialize
@@ -68,6 +85,7 @@ module OpenBlog
68
85
  @api_rate_limit = { to: 120, within: 1.minute }
69
86
  @search_rate_limit = { to: 30, within: 1.minute }
70
87
  @mcp = Mcp.new
88
+ @api_tokens = ApiTokens.new
71
89
  end
72
90
 
73
91
  def title_suffix
@@ -113,6 +131,7 @@ module OpenBlog
113
131
  validate_view_settings!
114
132
  invalid!(:mcp, "enabled must be true or false") unless [ true, false ].include?(mcp.enabled)
115
133
  invalid!(:mcp, "max_page_size must be a positive integer") unless mcp.max_page_size.is_a?(Integer) && mcp.max_page_size.positive?
134
+ validate_api_tokens!
116
135
  self
117
136
  end
118
137
 
@@ -131,8 +150,37 @@ module OpenBlog
131
150
  end
132
151
  end
133
152
 
153
+ def self.validate_hook!(name, hook, argument_count)
154
+ return if hook.nil?
155
+ raise ConfigurationError, "OpenBlog #{name} must be callable" unless hook.respond_to?(:call)
156
+ parameters = hook.is_a?(Proc) || hook.is_a?(Method) ? hook.parameters : hook.method(:call).parameters
157
+ required = parameters.count { |kind, _| kind == :req }
158
+ capacity = parameters.count { |kind, _| %i[req opt].include?(kind) }
159
+ rest = parameters.any? { |kind, _| kind == :rest }
160
+ keywords = parameters.any? { |kind, _| kind == :keyreq }
161
+ unless required <= argument_count && (rest || capacity >= argument_count) && !keywords
162
+ raise ConfigurationError, "OpenBlog #{name} must accept #{argument_count} positional arguments without required keywords"
163
+ end
164
+ end
165
+
134
166
  private
135
167
 
168
+ def validate_api_tokens!
169
+ settings = api_tokens
170
+ Configuration.validate_hook!(:identity, settings.identity, 1)
171
+ invalid!("api_tokens.policy", "must respond to grants_for") unless settings.policy.nil? || settings.policy.respond_to?(:grants_for)
172
+ invalid!("api_tokens.require_expiry", "must be true or false") unless [ true, false ].include?(settings.require_expiry)
173
+ { default_lifetime: false, max_lifetime: true, max_grace: false }.each do |key, nullable|
174
+ value = settings.public_send(key)
175
+ next if nullable && value.nil?
176
+ positive_finite = (value.is_a?(Numeric) || value.is_a?(ActiveSupport::Duration)) && value.to_f.finite? && value.to_f.positive?
177
+ invalid!("api_tokens.#{key}", "must be a positive finite duration") unless positive_finite
178
+ end
179
+ if settings.max_lifetime && settings.max_lifetime.to_f < settings.default_lifetime.to_f
180
+ invalid!("api_tokens.max_lifetime", "must be at least default_lifetime")
181
+ end
182
+ end
183
+
136
184
  def validate_theme!
137
185
  invalid!(:theme, "must be #{THEMES.map(&:inspect).join(', ')}") unless THEMES.include?(theme)
138
186
  invalid!(:faq_collapsed, "must be true or false") unless [ true, false ].include?(faq_collapsed)
@@ -202,16 +250,7 @@ module OpenBlog
202
250
  end
203
251
 
204
252
  def validate_hook!(name, hook, argument_count)
205
- return if hook.nil?
206
- invalid!(name, "must be callable") unless hook.respond_to?(:call)
207
- parameters = hook.is_a?(Proc) || hook.is_a?(Method) ? hook.parameters : hook.method(:call).parameters
208
- required = parameters.count { |kind, _| kind == :req }
209
- capacity = parameters.count { |kind, _| %i[req opt].include?(kind) }
210
- rest = parameters.any? { |kind, _| kind == :rest }
211
- keywords = parameters.any? { |kind, _| kind == :keyreq }
212
- unless required <= argument_count && (rest || capacity >= argument_count) && !keywords
213
- invalid!(name, "must accept #{argument_count} positional arguments without required keywords")
214
- end
253
+ Configuration.validate_hook!(name, hook, argument_count)
215
254
  end
216
255
 
217
256
  def invalid!(name, message)
@@ -11,6 +11,7 @@ module OpenBlog
11
11
 
12
12
  CHECKS = { "Configuration" => :configuration, "Migrations" => :migrations, "Route" => :route,
13
13
  "Views" => :views, "Theme" => :theme, "JavaScript" => :javascript, "Authentication" => :authentication,
14
+ "API token management" => :api_token_management,
14
15
  "Rate limit" => :rate_limit, "Jobs" => :jobs, "Storage" => :storage, "Record keys" => :record_keys, "Policy pages" => :policy_pages,
15
16
  "Records" => :records, "Declarations" => :declarations }.freeze
16
17
  TABLES = %w[open_blog_authors open_blog_categories open_blog_series open_blog_images open_blog_posts
@@ -86,6 +87,21 @@ module OpenBlog
86
87
  end
87
88
  end
88
89
 
90
+ def api_token_management
91
+ return [ "ok", "AdminSuite is not installed." ] unless defined?(OpenBlog::AdminSuite)
92
+ settings = @config.api_tokens
93
+ policy = settings.policy
94
+ problems = []
95
+ problems << "No identity hook: set config.api_tokens.identity." unless settings.identity
96
+ if policy.nil?
97
+ problems << "No grant policy: set config.api_tokens.policy."
98
+ elsif policy.respond_to?(:empty?) && policy.empty?
99
+ problems << "No grants: add an identity under api_tokens.grants in config/open_blog.yml."
100
+ end
101
+ problems << "Tokens inactive: config.authenticate is set, so the API ignores tokens." if @config.authenticate
102
+ problems.any? ? [ "warning", problems.join(" ") ] : [ "ok", "Token management is wired to a host identity and grants." ]
103
+ end
104
+
89
105
  def rate_limit
90
106
  key = "open-blog-doctor-#{SecureRandom.hex(12)}"
91
107
  store = @config.rate_limit_store
@@ -1,3 +1,3 @@
1
1
  module OpenBlog
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.0"
3
3
  end
data/lib/open_blog.rb CHANGED
@@ -6,6 +6,9 @@ require "mcp"
6
6
 
7
7
  require_relative "open_blog/version"
8
8
  require_relative "open_blog/instructions"
9
+ require_relative "open_blog/api_tokens/grant"
10
+ require_relative "open_blog/api_tokens/yaml_grants"
11
+ require_relative "open_blog/api_tokens"
9
12
 
10
13
  module OpenBlog
11
14
  FAQ_SELECTORS = { section: "[data-open-blog-faq]", entry: "[data-open-blog-faq-entry]" }.freeze
@@ -45,6 +45,23 @@ Place a single theme button in the shared navbar if requested; it is not a separ
45
45
 
46
46
  The controller stores `open-blog-theme` and sets `data-theme="light"` or `"dark"` on `<html>`; System removes both and follows the OS. Restore that same key before CSS loads on homepage and blog using the gem helper or one shared equivalent script with the host's CSP nonce. Keep one preference owner when the host already has a theme controller. Honor a fixed `config.color_scheme` by retaining its root attribute and omitting the toggle. Check Turbo transitions if used, full reloads, explicit light/dark, and system mode.
47
47
 
48
+ ## Wire API token management
49
+
50
+ Do this read-only discovery before asking questions or writing. Cite file evidence, and separate local checkout findings from deployed state.
51
+
52
+ 1. Read host instructions; note Open Blog and AdminSuite versions and existing customizations.
53
+ 2. Find the admin mount, its authentication, the trusted actor, stable identifiers, and whether user and admin sessions are separate.
54
+ 3. Find real authorization hooks, permission or role models, and tenant boundaries. Never treat roles as equivalent by name.
55
+ 4. Read any `config.api_tokens` settings and the API authentication mode (`config.authenticate` makes tokens inactive). Never retrieve or print stored credentials.
56
+ 5. Propose the smallest integration: `bin/rails generate open_blog:admin_suite`, an `api_tokens.identity` bound to the admin identity, and grants.
57
+ 6. Ask only unresolved policy questions: who gets own or all, which scopes, expiry rules, grace. Technical capability is not approval to grant.
58
+ 7. Prefer an existing permission system as `api_tokens.policy`; otherwise propose stable-identity grants in `config/open_blog.yml`. Get approval before real grants.
59
+ 8. Get installation authorization before writes. Deployment and production token issuance are separate permissions.
60
+ 9. In an authorized local or test environment, verify allowed and denied access, ownership isolation, scope limits, expiry, rotation, one-time reveal, audit secrecy, and that a rerun keeps edited files.
61
+ 10. Report verified behavior, blockers, and unverified production assumptions separately.
62
+
63
+ Do not recommend ECS stdout issuance, encryption wrappers, or temporary-file delivery as the recurring workflow.
64
+
48
65
  ## Upgrade copied files deliberately
49
66
 
50
67
  Read the target changelog, update any version constraint, then `bundle update open_blog --conservative`; review dependency changes too (on Rails 8.0, keep JSON below 3; Rails 8.1 can use JSON 3). Bundler does not refresh copied host files.
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: open_blog
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - TechWright Labs
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-28 00:00:00.000000000 Z
10
+ date: 2026-10-29 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: json
@@ -309,6 +309,7 @@ files:
309
309
  - app/models/concerns/open_blog/immutable_image_attachment.rb
310
310
  - app/models/concerns/open_blog/previewable.rb
311
311
  - app/models/open_blog/api_token.rb
312
+ - app/models/open_blog/api_token_event.rb
312
313
  - app/models/open_blog/application_record.rb
313
314
  - app/models/open_blog/approval.rb
314
315
  - app/models/open_blog/author.rb
@@ -343,6 +344,13 @@ files:
343
344
  - app/serializers/open_blog/series_serializer.rb
344
345
  - app/serializers/open_blog/tag_serializer.rb
345
346
  - app/serializers/open_blog/write_result_serializer.rb
347
+ - app/views/open_blog/admin_suite/api_tokens/_banner.html.erb
348
+ - app/views/open_blog/admin_suite/api_tokens/_error.html.erb
349
+ - app/views/open_blog/admin_suite/api_tokens/error.html.erb
350
+ - app/views/open_blog/admin_suite/api_tokens/index.html.erb
351
+ - app/views/open_blog/admin_suite/api_tokens/new.html.erb
352
+ - app/views/open_blog/admin_suite/api_tokens/revealed.html.erb
353
+ - app/views/open_blog/admin_suite/api_tokens/show.html.erb
346
354
  - app/views/open_blog/attachments/_blob.html.erb
347
355
  - app/views/open_blog/feeds/atom.xml.builder
348
356
  - app/views/open_blog/sitemaps/show.xml.builder
@@ -366,11 +374,14 @@ files:
366
374
  - db/migrate/20261003175454_create_open_blog_pages.rb
367
375
  - db/migrate/20261003182919_create_open_blog_page_views.rb
368
376
  - db/migrate/20261020120000_align_open_blog_record_keys.rb
377
+ - db/migrate/20261029120000_add_open_blog_api_token_management.rb
369
378
  - lib/generators/open_blog/admin_suite/admin_suite_generator.rb
379
+ - lib/generators/open_blog/admin_suite/templates/api_tokens_initializer.rb.tt
370
380
  - lib/generators/open_blog/admin_suite/templates/author_resource.rb.tt
371
381
  - lib/generators/open_blog/admin_suite/templates/blog_portal.rb.tt
372
382
  - lib/generators/open_blog/admin_suite/templates/category_resource.rb.tt
373
383
  - lib/generators/open_blog/admin_suite/templates/initializer.rb.tt
384
+ - lib/generators/open_blog/admin_suite/templates/open_blog.yml.tt
374
385
  - lib/generators/open_blog/admin_suite/templates/page_resource.rb.tt
375
386
  - lib/generators/open_blog/admin_suite/templates/post_resource.rb.tt
376
387
  - lib/generators/open_blog/generator_support.rb
@@ -423,10 +434,16 @@ files:
423
434
  - lib/open_blog.rb
424
435
  - lib/open_blog/actor.rb
425
436
  - lib/open_blog/admin_suite.rb
437
+ - lib/open_blog/admin_suite/api_tokens_controller.rb
426
438
  - lib/open_blog/adopt.rb
427
439
  - lib/open_blog/adopt/contract.rb
428
440
  - lib/open_blog/adopt/snapshot.rb
429
441
  - lib/open_blog/api_fields.rb
442
+ - lib/open_blog/api_tokens.rb
443
+ - lib/open_blog/api_tokens/grant.rb
444
+ - lib/open_blog/api_tokens/result.rb
445
+ - lib/open_blog/api_tokens/secret.rb
446
+ - lib/open_blog/api_tokens/yaml_grants.rb
430
447
  - lib/open_blog/approve.rb
431
448
  - lib/open_blog/authentication.rb
432
449
  - lib/open_blog/build_css.rb