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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/app/models/open_blog/api_token.rb +26 -5
- data/app/models/open_blog/api_token_event.rb +9 -0
- data/app/views/open_blog/admin_suite/api_tokens/_banner.html.erb +5 -0
- data/app/views/open_blog/admin_suite/api_tokens/_error.html.erb +3 -0
- data/app/views/open_blog/admin_suite/api_tokens/error.html.erb +6 -0
- data/app/views/open_blog/admin_suite/api_tokens/index.html.erb +47 -0
- data/app/views/open_blog/admin_suite/api_tokens/new.html.erb +44 -0
- data/app/views/open_blog/admin_suite/api_tokens/revealed.html.erb +30 -0
- data/app/views/open_blog/admin_suite/api_tokens/show.html.erb +80 -0
- data/db/migrate/20261029120000_add_open_blog_api_token_management.rb +18 -0
- data/lib/generators/open_blog/admin_suite/admin_suite_generator.rb +3 -0
- data/lib/generators/open_blog/admin_suite/templates/api_tokens_initializer.rb.tt +9 -0
- data/lib/generators/open_blog/admin_suite/templates/open_blog.yml.tt +2 -0
- data/lib/open_blog/admin_suite/api_tokens_controller.rb +150 -0
- data/lib/open_blog/admin_suite.rb +38 -0
- data/lib/open_blog/api_tokens/grant.rb +12 -0
- data/lib/open_blog/api_tokens/result.rb +13 -0
- data/lib/open_blog/api_tokens/secret.rb +33 -0
- data/lib/open_blog/api_tokens/yaml_grants.rb +67 -0
- data/lib/open_blog/api_tokens.rb +146 -0
- data/lib/open_blog/authentication.rb +4 -1
- data/lib/open_blog/configuration.rb +50 -11
- data/lib/open_blog/doctor.rb +16 -0
- data/lib/open_blog/version.rb +1 -1
- data/lib/open_blog.rb +3 -0
- data/skills/open-blog-install/SKILL.md +17 -0
- metadata +19 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 73849fe8c16692737f1e4ef73f9c2081832b804c1f07f5263c04d17fecc8340e
|
|
4
|
+
data.tar.gz: 55ae82fb654aedab55a31ce1f9eaa19283483cb79dbb0bc82bf32b280e98bf8f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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,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,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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/open_blog/doctor.rb
CHANGED
|
@@ -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
|
data/lib/open_blog/version.rb
CHANGED
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
|
+
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-
|
|
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
|