Skip to content

Commit dd2d27a

Browse files
feat: Admin add organization (#3856)
## Context This pr adds the create Organization endpoint for admin ## Description It adds a lightweight admin endpoint to create organizations via API, which is useful for self-hosted users and on-duty ops. POST /admin/organizations takes name and email, spins up the org with an admin invite, and returns both in the response. Authentication in Admin::BaseController now accepts either the existing Google ID token (Authorization: Bearer …) or a static admin key passed as X-Admin-API-Key, read from ADMIN_API_KEY
1 parent 16ebdf9 commit dd2d27a

File tree

5 files changed

+91
-12
lines changed

5 files changed

+91
-12
lines changed

app/controllers/admin/base_controller.rb

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,30 @@ class BaseController < ApplicationController
1212
def authenticate
1313
auth_header = request.headers["Authorization"]
1414

15-
return unauthorized_error unless auth_header
15+
if auth_header&.start_with?("Bearer ")
16+
begin
17+
token = auth_header.split(" ").second
18+
payload = Google::Auth::IDTokens.verify_oidc(
19+
token,
20+
aud: ENV["GOOGLE_AUTH_CLIENT_ID"]
21+
)
22+
23+
CurrentContext.email = payload["email"]
24+
return true
25+
rescue Google::Auth::IDTokens::SignatureError
26+
return unauthorized_error
27+
end
28+
end
29+
30+
# Fallback to X-Admin-API-Key header
31+
key_header = request.headers["X-Admin-API-Key"]
32+
expected_key = ENV["ADMIN_API_KEY"]
33+
34+
if key_header.present? && expected_key.present? && ActiveSupport::SecurityUtils.secure_compare(key_header, expected_key)
35+
CurrentContext.email = nil
36+
return true
37+
end
1638

17-
token = auth_header.split(" ").second
18-
payload = Google::Auth::IDTokens.verify_oidc(
19-
token,
20-
aud: ENV["GOOGLE_AUTH_CLIENT_ID"]
21-
)
22-
23-
CurrentContext.email = payload["email"]
24-
25-
true
26-
rescue Google::Auth::IDTokens::SignatureError
2739
unauthorized_error
2840
end
2941

app/controllers/admin/organizations_controller.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,28 @@ def update
1818
)
1919
end
2020

21+
def create
22+
result = ::Organizations::CreateService
23+
.call(name: create_params[:name], document_numbering: "per_organization")
24+
25+
return render_error_response(result) unless result.success?
26+
27+
organization = result.organization
28+
29+
invite_result = ::Invites::CreateService.call(
30+
current_organization: organization,
31+
email: create_params[:email],
32+
role: :admin
33+
)
34+
35+
return render_error_response(invite_result) unless invite_result.success?
36+
37+
render json: {
38+
organization: ::V1::OrganizationSerializer.new(organization).serialize,
39+
invite_url: invite_result.invite_url
40+
}, status: :created
41+
end
42+
2143
private
2244

2345
def organization
@@ -27,5 +49,9 @@ def organization
2749
def update_params
2850
params.permit(:name)
2951
end
52+
53+
def create_params
54+
params.permit(:name, :email)
55+
end
3056
end
3157
end

config/routes.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@
110110

111111
namespace :admin do
112112
resources :memberships, only: %i[create]
113-
resources :organizations, only: %i[update]
113+
resources :organizations, only: %i[update create]
114114
resources :invoices do
115115
post :regenerate, on: :member
116116
end

spec/requests/admin/organizations_controller_spec.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,39 @@
2626
end
2727
end
2828
end
29+
30+
describe "POST /admin/organizations" do
31+
let(:create_params) do
32+
{
33+
name: "NewCo",
34+
35+
}
36+
end
37+
38+
before do
39+
allow(ENV).to receive(:[]).and_call_original
40+
allow(ENV).to receive(:[]).with("ADMIN_API_KEY").and_return("super-secret")
41+
end
42+
43+
context "with a valid admin key" do
44+
it "creates an organization and returns 201" do
45+
headers = {"X-Admin-API-Key" => "super-secret"}
46+
expect do
47+
admin_post_without_bearer("/admin/organizations", create_params, headers)
48+
end.to change(Organization, :count).by(1)
49+
50+
expect(response).to have_http_status(:created)
51+
expect(json[:organization][:name]).to eq("NewCo")
52+
expect(json[:invite_url]).to be_present
53+
end
54+
end
55+
56+
context "with an invalid admin key" do
57+
it "returns unauthorized" do
58+
headers = {"X-Admin-API-Key" => "wrong"}
59+
admin_post_without_bearer("/admin/organizations", create_params, headers)
60+
expect(response).to have_http_status(:unauthorized)
61+
end
62+
end
63+
end
2964
end

spec/support/admin_helper.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ def admin_post(path, params = {}, headers = {})
1919
post(path, params: params.to_json, headers:)
2020
end
2121

22+
def admin_post_without_bearer(path, params = {}, headers = {})
23+
apply_headers(headers)
24+
headers.delete("Authorization")
25+
post(path, params: params.to_json, headers:)
26+
end
27+
2228
def json
2329
return response.body unless response.media_type.include?("json")
2430

0 commit comments

Comments
 (0)