open-loam 0.1.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 +7 -0
- data/CHANGELOG.md +71 -0
- data/LICENSE +21 -0
- data/README.md +177 -0
- data/app/jobs/loam/custom_field_reindex_job.rb +22 -0
- data/app/jobs/loam/event_delivery_job.rb +26 -0
- data/app/jobs/loam/event_redelivery_sweep_job.rb +21 -0
- data/app/jobs/loam/webhook_delivery_job.rb +59 -0
- data/app/models/loam/api_token.rb +40 -0
- data/app/models/loam/audit_record.rb +18 -0
- data/app/models/loam/auth_attempt.rb +13 -0
- data/app/models/loam/business_rule.rb +49 -0
- data/app/models/loam/business_rule_run.rb +27 -0
- data/app/models/loam/comment.rb +28 -0
- data/app/models/loam/config.rb +27 -0
- data/app/models/loam/custom_field_value.rb +18 -0
- data/app/models/loam/dashboard_widget.rb +16 -0
- data/app/models/loam/dictionary.rb +20 -0
- data/app/models/loam/dictionary_entry.rb +31 -0
- data/app/models/loam/event_delivery.rb +38 -0
- data/app/models/loam/field_definition.rb +48 -0
- data/app/models/loam/inbound_webhook_delivery.rb +24 -0
- data/app/models/loam/inbound_webhook_source.rb +55 -0
- data/app/models/loam/membership.rb +27 -0
- data/app/models/loam/mfa_credential.rb +114 -0
- data/app/models/loam/notification.rb +36 -0
- data/app/models/loam/pending_action.rb +208 -0
- data/app/models/loam/perspective.rb +104 -0
- data/app/models/loam/progress_job.rb +103 -0
- data/app/models/loam/record_lock.rb +19 -0
- data/app/models/loam/scheduled_job.rb +42 -0
- data/app/models/loam/search_token.rb +9 -0
- data/app/models/loam/sso_identity.rb +13 -0
- data/app/models/loam/sso_provider.rb +35 -0
- data/app/models/loam/tenant.rb +20 -0
- data/app/models/loam/translation.rb +17 -0
- data/app/models/loam/webhook_endpoint.rb +26 -0
- data/app/views/loam/attachments/_attachments.html.erb +16 -0
- data/app/views/loam/comments/_comments.html.erb +24 -0
- data/app/views/loam/custom_fields/_fields.html.erb +35 -0
- data/lib/generators/loam/entity/entity_generator.rb +160 -0
- data/lib/generators/loam/entity/templates/api_controller.rb +67 -0
- data/lib/generators/loam/entity/templates/controller.rb +181 -0
- data/lib/generators/loam/entity/templates/entity_test.rb +107 -0
- data/lib/generators/loam/entity/templates/migration.rb +26 -0
- data/lib/generators/loam/entity/templates/model.rb +27 -0
- data/lib/generators/loam/entity/templates/policy.rb +10 -0
- data/lib/generators/loam/entity/templates/views/_form.html.erb +63 -0
- data/lib/generators/loam/entity/templates/views/deleted.html.erb +41 -0
- data/lib/generators/loam/entity/templates/views/edit.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/index.html.erb +90 -0
- data/lib/generators/loam/entity/templates/views/new.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/show.html.erb +36 -0
- data/lib/generators/loam/install/install_generator.rb +288 -0
- data/lib/generators/loam/install/templates/AGENTS.md +341 -0
- data/lib/generators/loam/install/templates/admin/api_docs_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/api_docs_index.html.erb +34 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_controller.rb +30 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_index.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/base_controller.rb +224 -0
- data/lib/generators/loam/install/templates/admin/business_rules_controller.rb +64 -0
- data/lib/generators/loam/install/templates/admin/business_rules_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/business_rules_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/business_rules_index.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/business_rules_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/comments_controller.rb +39 -0
- data/lib/generators/loam/install/templates/admin/configs_controller.rb +46 -0
- data/lib/generators/loam/install/templates/admin/configs_edit.html.erb +22 -0
- data/lib/generators/loam/install/templates/admin/configs_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/dashboard_controller.rb +10 -0
- data/lib/generators/loam/install/templates/admin/dashboard_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_index.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_controller.rb +55 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_edit.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_form.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/dictionary_entries_controller.rb +47 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/events_controller.rb +42 -0
- data/lib/generators/loam/install/templates/admin/features_controller.rb +35 -0
- data/lib/generators/loam/install/templates/admin/features_index.html.erb +37 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_controller.rb +52 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_new.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/history_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/history_index.html.erb +35 -0
- data/lib/generators/loam/install/templates/admin/imports_controller.rb +75 -0
- data/lib/generators/loam/install/templates/admin/imports_new.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/imports_preview.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/imports_summary.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_new.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/layout.html.erb +76 -0
- data/lib/generators/loam/install/templates/admin/mfa_activated.html.erb +12 -0
- data/lib/generators/loam/install/templates/admin/mfa_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/mfa_new.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/mfa_show.html.erb +19 -0
- data/lib/generators/loam/install/templates/admin/notifications_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/notifications_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/overrides_controller.rb +14 -0
- data/lib/generators/loam/install/templates/admin/overrides_index.html.erb +29 -0
- data/lib/generators/loam/install/templates/admin/pagination.rb +23 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_index.html.erb +40 -0
- data/lib/generators/loam/install/templates/admin/perspectives_controller.rb +98 -0
- data/lib/generators/loam/install/templates/admin/perspectives_index.html.erb +38 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_controller.rb +19 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_index.html.erb +31 -0
- data/lib/generators/loam/install/templates/admin/record_locks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_controller.rb +69 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_edit.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_form.html.erb +16 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_index.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/search_controller.rb +49 -0
- data/lib/generators/loam/install/templates/admin/search_index.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/sessions_controller.rb +248 -0
- data/lib/generators/loam/install/templates/admin/sessions_mfa_challenge.html.erb +18 -0
- data/lib/generators/loam/install/templates/admin/sessions_new.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_controller.rb +65 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sudo_controller.rb +45 -0
- data/lib/generators/loam/install/templates/admin/sudo_new.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/translations_controller.rb +37 -0
- data/lib/generators/loam/install/templates/admin/translations_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_new.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin.css +29 -0
- data/lib/generators/loam/install/templates/api_base_controller.rb +90 -0
- data/lib/generators/loam/install/templates/guardrails_test.rb +79 -0
- data/lib/generators/loam/install/templates/import_job.rb +25 -0
- data/lib/generators/loam/install/templates/inbound_webhooks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/initializer.rb +218 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_api_tokens.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_audit_records.rb +14 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_auth_attempts.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_business_rules.rb +29 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_comments.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_configs.rb +21 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_custom_field_values.rb +19 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dashboard_widgets.rb +12 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionaries.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionary_entries.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_event_deliveries.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_field_definitions.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_inbound_webhooks.rb +31 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_memberships.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_mfa_credentials.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_notifications.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_pending_actions.rb +26 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_perspectives.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_progress_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_record_locks.rb +16 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_scheduled_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_search_tokens.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_sso_providers.rb +34 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_tenants.rb +9 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_translations.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_webhook_endpoints.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_users.rb +13 -0
- data/lib/generators/loam/install/templates/user.rb +21 -0
- data/lib/generators/loam/primary_key_options.rb +101 -0
- data/lib/loam/attachable.rb +27 -0
- data/lib/loam/auditable.rb +67 -0
- data/lib/loam/auth_throttle.rb +83 -0
- data/lib/loam/base32.rb +23 -0
- data/lib/loam/bulk.rb +59 -0
- data/lib/loam/business_rules/actions.rb +92 -0
- data/lib/loam/business_rules/condition.rb +102 -0
- data/lib/loam/business_rules.rb +123 -0
- data/lib/loam/commentable.rb +22 -0
- data/lib/loam/configs.rb +115 -0
- data/lib/loam/cron.rb +98 -0
- data/lib/loam/csv.rb +18 -0
- data/lib/loam/current.rb +21 -0
- data/lib/loam/custom_field_index.rb +262 -0
- data/lib/loam/custom_fields.rb +114 -0
- data/lib/loam/dashboard.rb +17 -0
- data/lib/loam/dictionaries.rb +55 -0
- data/lib/loam/durable_events.rb +174 -0
- data/lib/loam/encryptable.rb +145 -0
- data/lib/loam/encryption/cipher.rb +75 -0
- data/lib/loam/encryption/key_provider.rb +43 -0
- data/lib/loam/encryption.rb +125 -0
- data/lib/loam/engine.rb +57 -0
- data/lib/loam/enrichers.rb +105 -0
- data/lib/loam/errors.rb +49 -0
- data/lib/loam/eval.rb +47 -0
- data/lib/loam/event_stream.rb +80 -0
- data/lib/loam/eventful.rb +45 -0
- data/lib/loam/events.rb +51 -0
- data/lib/loam/export.rb +74 -0
- data/lib/loam/features.rb +75 -0
- data/lib/loam/generated_key.rb +37 -0
- data/lib/loam/import.rb +168 -0
- data/lib/loam/inbound_webhooks.rb +125 -0
- data/lib/loam/lifecycle.rb +180 -0
- data/lib/loam/locales/loam.en.yml +80 -0
- data/lib/loam/mcp/server.rb +60 -0
- data/lib/loam/mcp.rb +304 -0
- data/lib/loam/notifications.rb +36 -0
- data/lib/loam/open_api.rb +194 -0
- data/lib/loam/overrides.rb +110 -0
- data/lib/loam/pending_actions.rb +60 -0
- data/lib/loam/permissions.rb +79 -0
- data/lib/loam/perspectives.rb +62 -0
- data/lib/loam/policy.rb +106 -0
- data/lib/loam/progress.rb +31 -0
- data/lib/loam/record_locks.rb +89 -0
- data/lib/loam/scheduler.rb +153 -0
- data/lib/loam/search/driver.rb +31 -0
- data/lib/loam/search/like_driver.rb +29 -0
- data/lib/loam/search/token_driver.rb +88 -0
- data/lib/loam/search.rb +27 -0
- data/lib/loam/searchable.rb +90 -0
- data/lib/loam/soft_deletable.rb +72 -0
- data/lib/loam/sso/claims.rb +10 -0
- data/lib/loam/sso/fake_provider.rb +51 -0
- data/lib/loam/sso/http_client.rb +42 -0
- data/lib/loam/sso/oidc_provider.rb +64 -0
- data/lib/loam/sso.rb +129 -0
- data/lib/loam/telemetry.rb +43 -0
- data/lib/loam/tenant_record.rb +36 -0
- data/lib/loam/test_helpers.rb +15 -0
- data/lib/loam/totp.rb +63 -0
- data/lib/loam/translatable.rb +88 -0
- data/lib/loam/undo.rb +126 -0
- data/lib/loam/version.rb +3 -0
- data/lib/loam/webhooks.rb +34 -0
- data/lib/loam/widgets.rb +77 -0
- data/lib/loam/workflow.rb +256 -0
- data/lib/loam.rb +122 -0
- data/lib/open-loam.rb +5 -0
- data/lib/tasks/loam.rake +171 -0
- data/lib/tasks/loam_eval.rake +32 -0
- data/lib/tasks/loam_mcp.rake +20 -0
- metadata +332 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<%% if @error %>
|
|
2
|
+
<p style="color:#b00"><%%= @error %></p>
|
|
3
|
+
<%% end %>
|
|
4
|
+
|
|
5
|
+
<h1>Two-factor authentication</h1>
|
|
6
|
+
|
|
7
|
+
<p>Signed in as <strong><%%= @user.name %></strong>. Enter the 6-digit code from
|
|
8
|
+
your authenticator app, or one of your recovery codes.</p>
|
|
9
|
+
|
|
10
|
+
<%%= form_with url: mfa_verify_admin_session_path, method: :post do |f| %>
|
|
11
|
+
<p>
|
|
12
|
+
<%%= label_tag :code, "Code" %><br>
|
|
13
|
+
<%%= text_field_tag :code, nil, autofocus: true, autocomplete: "one-time-code", inputmode: "numeric" %>
|
|
14
|
+
</p>
|
|
15
|
+
<%%= f.submit "Verify" %>
|
|
16
|
+
<%% end %>
|
|
17
|
+
|
|
18
|
+
<%%= button_to "Cancel and sign out", admin_session_path, method: :delete %>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<%% if flash[:alert] %>
|
|
2
|
+
<p style="color:#b00"><%%= flash[:alert] %></p>
|
|
3
|
+
<%% end %>
|
|
4
|
+
<%% if @error %>
|
|
5
|
+
<p style="color:#b00"><%%= @error %></p>
|
|
6
|
+
<%% end %>
|
|
7
|
+
|
|
8
|
+
<%% if @user %>
|
|
9
|
+
<h1>Choose a tenant</h1>
|
|
10
|
+
|
|
11
|
+
<p>Signed in as <strong><%%= @user.name %></strong>. You are a member of more than one tenant.</p>
|
|
12
|
+
|
|
13
|
+
<%%= form_with url: select_tenant_admin_session_path, method: :post do |f| %>
|
|
14
|
+
<p>
|
|
15
|
+
<%%= select_tag :tenant_id, options_from_collection_for_select(@tenants, :id, :name) %>
|
|
16
|
+
</p>
|
|
17
|
+
<%%= f.submit "Enter" %>
|
|
18
|
+
<%% end %>
|
|
19
|
+
|
|
20
|
+
<%%= button_to "Sign out", admin_session_path, method: :delete %>
|
|
21
|
+
<%% else %>
|
|
22
|
+
<h1>Sign in</h1>
|
|
23
|
+
|
|
24
|
+
<%%= form_with url: admin_session_path, method: :post do |f| %>
|
|
25
|
+
<p>
|
|
26
|
+
<%%= label_tag :email %><br>
|
|
27
|
+
<%%= email_field_tag :email, params[:email], autofocus: true %>
|
|
28
|
+
</p>
|
|
29
|
+
<p>
|
|
30
|
+
<%%= label_tag :password %><br>
|
|
31
|
+
<%%= password_field_tag :password %>
|
|
32
|
+
</p>
|
|
33
|
+
<%%= f.submit "Sign in" %>
|
|
34
|
+
<%% end %>
|
|
35
|
+
|
|
36
|
+
<h2>Or sign in with SSO</h2>
|
|
37
|
+
<p>Enter your work email; if your organization has single sign-on, we'll send you to it.</p>
|
|
38
|
+
<%%= form_with url: sso_start_admin_session_path, method: :post do |f| %>
|
|
39
|
+
<p>
|
|
40
|
+
<%%= label_tag :sso_email, "Work email" %><br>
|
|
41
|
+
<%%= email_field_tag :email, params[:email] %>
|
|
42
|
+
</p>
|
|
43
|
+
<%%= f.submit "Continue with SSO" %>
|
|
44
|
+
<%% end %>
|
|
45
|
+
<%% end %>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
# Configure this tenant's SSO (OIDC) provider — manager-only, since it governs
|
|
3
|
+
# who can sign in and at what role. Tenant-scoped by Loam::SsoProvider's default
|
|
4
|
+
# scope, so a manager only ever sees and edits their own tenant's connection.
|
|
5
|
+
# The client_secret is WRITE-ONLY: it is encrypted at rest and never rendered
|
|
6
|
+
# back, so editing without retyping it leaves the stored secret untouched.
|
|
7
|
+
class SsoProvidersController < BaseController
|
|
8
|
+
before_action { require_role!(:manager) }
|
|
9
|
+
before_action :set_provider, only: %i[edit update destroy]
|
|
10
|
+
|
|
11
|
+
rescue_from JSON::ParserError do |error|
|
|
12
|
+
flash.now[:alert] = "The group→role map must be valid JSON: #{error.message}"
|
|
13
|
+
@provider ||= Loam::SsoProvider.new
|
|
14
|
+
render(@provider.persisted? ? :edit : :new, status: :unprocessable_entity)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def index
|
|
18
|
+
@providers = Loam::SsoProvider.order(:domain)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def new
|
|
22
|
+
@provider = Loam::SsoProvider.new(protocol: "oidc", active: true, jit_role: "employee")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create
|
|
26
|
+
@provider = Loam::SsoProvider.new(provider_params)
|
|
27
|
+
if @provider.save
|
|
28
|
+
redirect_to admin_sso_providers_path, notice: "SSO provider created."
|
|
29
|
+
else
|
|
30
|
+
render :new, status: :unprocessable_entity
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def edit; end
|
|
35
|
+
|
|
36
|
+
def update
|
|
37
|
+
if @provider.update(provider_params)
|
|
38
|
+
redirect_to admin_sso_providers_path, notice: "SSO provider updated."
|
|
39
|
+
else
|
|
40
|
+
render :edit, status: :unprocessable_entity
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def destroy
|
|
45
|
+
@provider.destroy!
|
|
46
|
+
redirect_to admin_sso_providers_path, notice: "SSO provider deleted."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def set_provider
|
|
52
|
+
@provider = Loam::SsoProvider.find(params[:id])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def provider_params
|
|
56
|
+
permitted = params.require(:sso_provider).permit(:name, :protocol, :issuer, :client_id, :domain, :jit_role, :active)
|
|
57
|
+
# Write-only: only assign the secret when a new value was typed, so a blank
|
|
58
|
+
# field on edit keeps the encrypted one already stored.
|
|
59
|
+
secret = params[:sso_provider][:client_secret]
|
|
60
|
+
permitted[:client_secret] = secret if secret.present?
|
|
61
|
+
permitted[:group_role_map] = JSON.parse(params[:sso_provider][:group_role_map].presence || "{}")
|
|
62
|
+
permitted
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<%% if @error || flash[:alert] %>
|
|
2
|
+
<p style="color:#b00"><%%= @error || flash[:alert] %></p>
|
|
3
|
+
<%% end %>
|
|
4
|
+
|
|
5
|
+
<%# The model is namespaced (Loam::SsoProvider), so an inferred URL/param-scope
|
|
6
|
+
would be admin_loam_sso_provider_path / loam_sso_provider[...]. Pin both to
|
|
7
|
+
the un-namespaced route and the "sso_provider" key the controller permits. %>
|
|
8
|
+
<%%= form_with model: @provider, scope: :sso_provider,
|
|
9
|
+
url: (@provider.persisted? ? admin_sso_provider_path(@provider) : admin_sso_providers_path) do |f| %>
|
|
10
|
+
<%% if @provider.errors.any? %>
|
|
11
|
+
<ul><%% @provider.errors.full_messages.each do |m| %><li><%%= m %></li><%% end %></ul>
|
|
12
|
+
<%% end %>
|
|
13
|
+
|
|
14
|
+
<p><%%= f.label :name %><br><%%= f.text_field :name %></p>
|
|
15
|
+
<p><%%= f.label :domain, "Email domain (for home-realm discovery, e.g. acme.example)" %><br><%%= f.text_field :domain %></p>
|
|
16
|
+
<p><%%= f.label :issuer, "OIDC issuer URL (its .well-known is discovered)" %><br><%%= f.text_field :issuer %></p>
|
|
17
|
+
<p><%%= f.label :client_id %><br><%%= f.text_field :client_id %></p>
|
|
18
|
+
<p>
|
|
19
|
+
<%%= f.label :client_secret, "Client secret (encrypted; leave blank to keep the current one)" %><br>
|
|
20
|
+
<%%= f.password_field :client_secret, value: "", autocomplete: "off" %>
|
|
21
|
+
</p>
|
|
22
|
+
<p><%%= f.label :jit_role, "Default role for new (JIT) users" %><br><%%= f.text_field :jit_role %></p>
|
|
23
|
+
<p>
|
|
24
|
+
<%%= f.label :group_role_map, "IdP group → role map (JSON, optional)" %><br>
|
|
25
|
+
<%%= f.text_area :group_role_map, value: JSON.pretty_generate(@provider.group_roles), rows: 4, cols: 40 %>
|
|
26
|
+
</p>
|
|
27
|
+
<p><%%= f.label :active %> <%%= f.check_box :active %></p>
|
|
28
|
+
|
|
29
|
+
<%%= f.submit %>
|
|
30
|
+
<%% end %>
|
|
31
|
+
|
|
32
|
+
<p><%%= link_to "Back", admin_sso_providers_path %></p>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<h1>SSO providers</h1>
|
|
2
|
+
|
|
3
|
+
<p>Single sign-on for this tenant. A user whose email domain matches a provider
|
|
4
|
+
below is sent to that identity provider to sign in (OIDC), and provisioned on
|
|
5
|
+
first login. <%%= link_to "New provider", new_admin_sso_provider_path %></p>
|
|
6
|
+
|
|
7
|
+
<table>
|
|
8
|
+
<thead>
|
|
9
|
+
<tr><th>Name</th><th>Protocol</th><th>Domain</th><th>JIT role</th><th>Active</th><th></th></tr>
|
|
10
|
+
</thead>
|
|
11
|
+
<tbody>
|
|
12
|
+
<%% @providers.each do |provider| %>
|
|
13
|
+
<tr>
|
|
14
|
+
<td><%%= provider.name %></td>
|
|
15
|
+
<td><%%= provider.protocol %></td>
|
|
16
|
+
<td><code><%%= provider.domain %></code></td>
|
|
17
|
+
<td><%%= provider.jit_role %></td>
|
|
18
|
+
<td><%%= provider.active? ? "yes" : "no" %></td>
|
|
19
|
+
<td>
|
|
20
|
+
<%%= link_to "Edit", edit_admin_sso_provider_path(provider) %>
|
|
21
|
+
<%%= button_to "Delete", admin_sso_provider_path(provider), method: :delete, form: { style: "display:inline" } %>
|
|
22
|
+
</td>
|
|
23
|
+
</tr>
|
|
24
|
+
<%% end %>
|
|
25
|
+
</tbody>
|
|
26
|
+
</table>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
# The step-up ("sudo") re-challenge. A sensitive action calls require_sudo!,
|
|
3
|
+
# which detours here when the last authentication is stale; a correct password
|
|
4
|
+
# (or TOTP code, if the user has MFA) stamps a fresh sudo timestamp and returns
|
|
5
|
+
# to wherever they were headed.
|
|
6
|
+
class SudoController < BaseController
|
|
7
|
+
def new
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
identifier = "#{current_actor.email}:sudo" # a distinct throttle bucket from login
|
|
12
|
+
|
|
13
|
+
if Loam::AuthThrottle.locked?(identifier)
|
|
14
|
+
@error = "Too many attempts. Try again later."
|
|
15
|
+
return render :new, status: :too_many_requests
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if reauthenticated?
|
|
19
|
+
Loam::AuthThrottle.clear(identifier)
|
|
20
|
+
session[:sudo_at] = Time.now.to_i
|
|
21
|
+
redirect_to(session.delete(:sudo_return_to).presence || admin_root_path,
|
|
22
|
+
notice: "Re-authenticated — you can complete the action now.")
|
|
23
|
+
else
|
|
24
|
+
Loam::AuthThrottle.record_failure(identifier, kind: "sudo", ip: request.remote_ip)
|
|
25
|
+
@error = "That did not verify. Try again."
|
|
26
|
+
render :new, status: :unauthorized
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# If the user has MFA, step-up takes a TOTP code — NOT a recovery code:
|
|
33
|
+
# recovery codes are single-use and reserved for LOGIN, so a routine sudo
|
|
34
|
+
# must never silently burn one. Otherwise fall back to the password. Either
|
|
35
|
+
# way it is the SAME person in Loam::Current re-confirming.
|
|
36
|
+
def reauthenticated?
|
|
37
|
+
credential = Loam::MfaCredential.active_for(current_actor)
|
|
38
|
+
if credential
|
|
39
|
+
credential.verify_totp(params[:code])
|
|
40
|
+
else
|
|
41
|
+
User.authenticate_by(email: current_actor.email, password: params[:password].to_s).present?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%% if @error %>
|
|
2
|
+
<p style="color:#b00"><%%= @error %></p>
|
|
3
|
+
<%% end %>
|
|
4
|
+
|
|
5
|
+
<h1>Confirm it's you</h1>
|
|
6
|
+
|
|
7
|
+
<p>This action is sensitive, so please re-authenticate.</p>
|
|
8
|
+
|
|
9
|
+
<%%= form_with url: admin_sudo_path, method: :post do |f| %>
|
|
10
|
+
<%% if Loam::MfaCredential.active_for(current_actor) %>
|
|
11
|
+
<p>
|
|
12
|
+
<%%= label_tag :code, "Authenticator code" %><br>
|
|
13
|
+
<%%= text_field_tag :code, nil, autofocus: true, autocomplete: "one-time-code", inputmode: "numeric" %>
|
|
14
|
+
</p>
|
|
15
|
+
<%% else %>
|
|
16
|
+
<p>
|
|
17
|
+
<%%= label_tag :password %><br>
|
|
18
|
+
<%%= password_field_tag :password, nil, autofocus: true %>
|
|
19
|
+
</p>
|
|
20
|
+
<%% end %>
|
|
21
|
+
<%%= f.submit "Confirm" %>
|
|
22
|
+
<%% end %>
|
|
23
|
+
|
|
24
|
+
<p><%%= link_to "Cancel", admin_root_path %></p>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
# Per-record content translations (Loam::Translatable) — manager-only, since
|
|
3
|
+
# translating content is a privileged edit. Scoped by translatable_type +
|
|
4
|
+
# translatable_id; edits each translatable field across Loam.locales. The base
|
|
5
|
+
# (default-locale) value stays on the record itself and is shown read-only for
|
|
6
|
+
# reference — translations are additive.
|
|
7
|
+
class TranslationsController < BaseController
|
|
8
|
+
before_action { require_role!(:manager) }
|
|
9
|
+
before_action :set_record
|
|
10
|
+
|
|
11
|
+
def index; end
|
|
12
|
+
|
|
13
|
+
def update
|
|
14
|
+
params.require(:translations).to_unsafe_h.each do |field, by_locale|
|
|
15
|
+
next unless @record.class.loam_translatable?(field)
|
|
16
|
+
|
|
17
|
+
by_locale.each { |locale, value| @record.set_translation(field, locale, value) if Loam.locales.include?(locale) }
|
|
18
|
+
end
|
|
19
|
+
redirect_to translate_path, notice: "Translations saved."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def set_record
|
|
25
|
+
model = Loam::Import.allowed_model(params[:translatable_type]) # whitelist: a Loam entity only
|
|
26
|
+
raise ActiveRecord::RecordNotFound unless model.include?(Loam::Translatable)
|
|
27
|
+
|
|
28
|
+
@record = model.find(params[:translatable_id])
|
|
29
|
+
@fields = model.loam_translatable_attributes
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def translate_path
|
|
33
|
+
admin_translations_path(translatable_type: @record.class.name, translatable_id: @record.id)
|
|
34
|
+
end
|
|
35
|
+
helper_method :translate_path
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<h1>Translations — <%%= @record.class.model_name.human %> #<%%= @record.id %></h1>
|
|
2
|
+
|
|
3
|
+
<p>Translate content per locale. The base (<%%= Loam.default_locale.upcase %>) value
|
|
4
|
+
stays on the record; other locales overlay onto it, falling back to the base when
|
|
5
|
+
empty.</p>
|
|
6
|
+
|
|
7
|
+
<%%= form_with url: translate_path, method: :patch do %>
|
|
8
|
+
<%% @fields.each do |field| %>
|
|
9
|
+
<h3><%%= field.humanize %></h3>
|
|
10
|
+
<p><em>Base (<%%= Loam.default_locale.upcase %>):</em> <%%= @record.read_attribute(field) %></p>
|
|
11
|
+
<%% (Loam.locales - [Loam.default_locale]).each do |locale| %>
|
|
12
|
+
<p>
|
|
13
|
+
<%%= label_tag "translations[#{field}][#{locale}]", locale.upcase %><br>
|
|
14
|
+
<%%= text_field_tag "translations[#{field}][#{locale}]", @record.translations_for(field)[locale] %>
|
|
15
|
+
</p>
|
|
16
|
+
<%% end %>
|
|
17
|
+
<%% end %>
|
|
18
|
+
<%%= submit_tag "Save translations" %>
|
|
19
|
+
<%% end %>
|
|
20
|
+
|
|
21
|
+
<p><%%= link_to "Back", [:admin, @record] %></p>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
# Where this tenant's events get delivered. Structural rather than
|
|
3
|
+
# per-record, like field definitions, so it is gated on the manager role
|
|
4
|
+
# instead of a generated entity policy.
|
|
5
|
+
class WebhookEndpointsController < BaseController
|
|
6
|
+
before_action { require_role!(:manager) }
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@records = Loam::WebhookEndpoint.order(:event_pattern)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def new
|
|
13
|
+
@record = Loam::WebhookEndpoint.new(active: true)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create
|
|
17
|
+
@record = Loam::WebhookEndpoint.new(permitted_params)
|
|
18
|
+
|
|
19
|
+
if @record.save
|
|
20
|
+
redirect_to admin_webhook_endpoints_path
|
|
21
|
+
else
|
|
22
|
+
render :new, status: :unprocessable_entity
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def destroy
|
|
27
|
+
Loam::WebhookEndpoint.find(params[:id]).destroy!
|
|
28
|
+
redirect_to admin_webhook_endpoints_path
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# The secret is generated, never submitted.
|
|
34
|
+
def permitted_params
|
|
35
|
+
params.require(:webhook_endpoint).permit(:url, :event_pattern, :active)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<h1>Webhook endpoints</h1>
|
|
2
|
+
|
|
3
|
+
<p>Domain events delivered over HTTP. A pattern ending in a dot matches a whole
|
|
4
|
+
domain ("rental."); anything else is one exact event name.</p>
|
|
5
|
+
|
|
6
|
+
<%%= link_to "New endpoint", new_admin_webhook_endpoint_path %>
|
|
7
|
+
|
|
8
|
+
<table>
|
|
9
|
+
<thead>
|
|
10
|
+
<tr>
|
|
11
|
+
<th>Event pattern</th>
|
|
12
|
+
<th>URL</th>
|
|
13
|
+
<th>Active</th>
|
|
14
|
+
<th>Secret</th>
|
|
15
|
+
<th></th>
|
|
16
|
+
</tr>
|
|
17
|
+
</thead>
|
|
18
|
+
<tbody>
|
|
19
|
+
<%% @records.each do |record| %>
|
|
20
|
+
<tr>
|
|
21
|
+
<td><%%= record.event_pattern %></td>
|
|
22
|
+
<td><%%= record.url %></td>
|
|
23
|
+
<td><%%= record.active? ? "yes" : "no" %></td>
|
|
24
|
+
<td><code><%%= record.secret %></code></td>
|
|
25
|
+
<td><%%= button_to "Delete", admin_webhook_endpoint_path(record), method: :delete, form: { style: "display:inline" } %></td>
|
|
26
|
+
</tr>
|
|
27
|
+
<%% end %>
|
|
28
|
+
</tbody>
|
|
29
|
+
</table>
|
|
30
|
+
|
|
31
|
+
<p>Each delivery is signed: <code>X-Loam-Signature: sha256=HMAC-SHA256(secret, body)</code>.
|
|
32
|
+
Recompute it on your side and reject anything that does not match.</p>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<h1>New webhook endpoint</h1>
|
|
2
|
+
|
|
3
|
+
<%%= form_with model: @record, url: admin_webhook_endpoints_path do |f| %>
|
|
4
|
+
<%% if @record.errors.any? %>
|
|
5
|
+
<ul>
|
|
6
|
+
<%% @record.errors.full_messages.each do |message| %>
|
|
7
|
+
<li><%%= message %></li>
|
|
8
|
+
<%% end %>
|
|
9
|
+
</ul>
|
|
10
|
+
<%% end %>
|
|
11
|
+
|
|
12
|
+
<p>
|
|
13
|
+
<%%= f.label :event_pattern, "Event pattern (\"rental.\" for a whole domain, or one exact event name)" %><br>
|
|
14
|
+
<%%= f.text_field :event_pattern %>
|
|
15
|
+
</p>
|
|
16
|
+
<p>
|
|
17
|
+
<%%= f.label :url, "URL (http:// or https://)" %><br>
|
|
18
|
+
<%%= f.text_field :url %>
|
|
19
|
+
</p>
|
|
20
|
+
<p>
|
|
21
|
+
<%%= f.label :active %>
|
|
22
|
+
<%%= f.check_box :active %>
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
<%%= f.submit "Create endpoint" %>
|
|
26
|
+
<%% end %>
|
|
27
|
+
|
|
28
|
+
<p>A signing secret is generated for you and shown on the list.</p>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* Loam admin console — shared styles (extracted from the layout, L-402). */
|
|
2
|
+
body { font-family: system-ui, -apple-system, sans-serif; margin: 2rem; color: #1c1c1a; line-height: 1.5; }
|
|
3
|
+
h1 { font-size: 1.55rem; margin: 0 0 0.75rem; }
|
|
4
|
+
h2 { font-size: 1.2rem; margin: 1.25rem 0 0.5rem; }
|
|
5
|
+
a { color: #1a5a9a; }
|
|
6
|
+
|
|
7
|
+
table { border-collapse: collapse; margin: 0.5rem 0 1rem; }
|
|
8
|
+
th, td { border: 1px solid #d5d5cf; padding: 0.4rem 0.8rem; text-align: left; vertical-align: top; }
|
|
9
|
+
th { background: #f5f4f0; font-weight: 600; }
|
|
10
|
+
|
|
11
|
+
nav { margin-bottom: 1.5rem; padding-bottom: 0.75rem; border-bottom: 1px solid #e4e2db; }
|
|
12
|
+
nav a { margin-right: 1rem; }
|
|
13
|
+
nav form { display: inline; }
|
|
14
|
+
.context { color: #555; font-size: 0.9rem; margin-bottom: 1rem; }
|
|
15
|
+
|
|
16
|
+
form p { margin: 0.5rem 0; }
|
|
17
|
+
input, select, textarea { padding: 0.3rem 0.4rem; font: inherit; }
|
|
18
|
+
button, input[type="submit"] { padding: 0.35rem 0.8rem; font: inherit; cursor: pointer; }
|
|
19
|
+
|
|
20
|
+
/* Flash messages — controllers set notice:/alert:, the layout renders them. */
|
|
21
|
+
.flash { padding: 0.6rem 0.9rem; border-radius: 6px; margin: 0 0 1rem; max-width: 60rem; }
|
|
22
|
+
.flash-notice { background: #e6f4ea; border: 1px solid #b7dfc4; color: #1e5631; }
|
|
23
|
+
.flash-alert { background: #fdecec; border: 1px solid #f2b8b8; color: #8a1c1c; }
|
|
24
|
+
|
|
25
|
+
.conflict { border: 2px solid #b00; padding: 0.75rem; margin-bottom: 1rem; }
|
|
26
|
+
.lock-banner { background: #fffbe6; border: 1px solid #ece2a6; padding: 0.5rem; margin-bottom: 1rem; }
|
|
27
|
+
|
|
28
|
+
code { background: #f3f0e9; padding: 0.05em 0.3em; border-radius: 3px; }
|
|
29
|
+
small { color: #666; }
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module Api
|
|
2
|
+
# Base class for every JSON endpoint. Authentication is a bearer token:
|
|
3
|
+
#
|
|
4
|
+
# curl -H "Authorization: Bearer <token>" http://localhost:3000/api/equipment
|
|
5
|
+
#
|
|
6
|
+
# A token identifies one user in one tenant, and `Loam::ApiToken.authenticate`
|
|
7
|
+
# establishes both in Loam::Current before any action runs. From that point
|
|
8
|
+
# on this is ordinary tenant-scoped Loam code: every query is filtered, every
|
|
9
|
+
# write is audited, every policy applies — the API is not a side door.
|
|
10
|
+
#
|
|
11
|
+
# Finding a token is the one lookup that cannot start from a tenant (the
|
|
12
|
+
# token is what reveals the tenant), which is why it lives in the gem rather
|
|
13
|
+
# than here: host app code must never reach across tenants.
|
|
14
|
+
class BaseController < ActionController::API
|
|
15
|
+
before_action :authenticate_api_token!
|
|
16
|
+
|
|
17
|
+
rescue_from Loam::NotAuthorizedError do
|
|
18
|
+
render json: { error: "forbidden" }, status: :forbidden
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
rescue_from ActiveRecord::RecordNotFound do
|
|
22
|
+
render json: { error: "not_found" }, status: :not_found
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
rescue_from Loam::UnknownCustomFieldError do |error|
|
|
26
|
+
render json: { error: error.message }, status: :unprocessable_entity
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def authenticate_api_token!
|
|
32
|
+
@api_token = Loam::ApiToken.authenticate(bearer_token)
|
|
33
|
+
|
|
34
|
+
render json: { error: "unauthorized" }, status: :unauthorized unless @api_token
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def bearer_token
|
|
38
|
+
request.headers["Authorization"].to_s[/\ABearer (.+)\z/, 1]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def current_tenant = Loam::Current.tenant
|
|
42
|
+
def current_actor = Loam::Current.actor
|
|
43
|
+
|
|
44
|
+
def policy_for(record)
|
|
45
|
+
Loam::Policy.for(record)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def authorize!(policy, action)
|
|
49
|
+
raise Loam::NotAuthorizedError unless policy.public_send(action)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The JSON shape of an entity: its columns, custom fields included (they
|
|
53
|
+
# live in the `custom_fields` column that every generated entity carries).
|
|
54
|
+
#
|
|
55
|
+
# Encrypted fields (Loam::Encryptable) are returned DECRYPTED — the caller is
|
|
56
|
+
# authenticated, tenant-scoped and policy-gated, exactly like the admin show
|
|
57
|
+
# screen — and their blind-index `<field>_hash` column is dropped, so the
|
|
58
|
+
# equality-leaking hash never goes over the wire. `record.attributes` alone
|
|
59
|
+
# would emit the raw ciphertext plus the hash.
|
|
60
|
+
def entity_json(record, enrichments: nil)
|
|
61
|
+
json = record.attributes
|
|
62
|
+
|
|
63
|
+
if record.class.respond_to?(:loam_encrypted_attributes)
|
|
64
|
+
record.class.loam_encrypted_attributes.each do |name|
|
|
65
|
+
json[name] = record.public_send(name)
|
|
66
|
+
json.delete("#{name}_hash")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Computed cross-module blocks (Loam::Enrichers), under a separate key so
|
|
71
|
+
# they're never confused with the record's own columns. An index passes the
|
|
72
|
+
# batched result (avoiding N+1); a single show computes it here.
|
|
73
|
+
enrichments = Loam::Enrichers.enrich(record) if enrichments.nil?
|
|
74
|
+
json["enrichments"] = enrichments if enrichments.present?
|
|
75
|
+
json
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Same field-level enforcement as the admin screens: only a definition the
|
|
79
|
+
# actor's role may write is assigned. Custom fields ride at the top level
|
|
80
|
+
# of the request body, beside the entity key.
|
|
81
|
+
def assign_custom_fields!(record, params, policy)
|
|
82
|
+
submitted = params[:custom_fields]
|
|
83
|
+
return unless submitted
|
|
84
|
+
|
|
85
|
+
submitted.each do |name, value|
|
|
86
|
+
record.set_custom_field(name, value) if policy.custom_field_writable?(name)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
require "test_helper"
|
|
2
|
+
|
|
3
|
+
# Structural guardrails. These are not feature tests — they enforce Loam's
|
|
4
|
+
# invariants on the codebase itself. If one of these fails, an agent (or a
|
|
5
|
+
# human) stepped outside the conventions.
|
|
6
|
+
class LoamGuardrailsTest < ActiveSupport::TestCase
|
|
7
|
+
# Models that are legitimately not tenant-scoped. Loam::Config holds both
|
|
8
|
+
# global (tenant_id NULL) and per-tenant override rows, so tenancy is a
|
|
9
|
+
# nullable column and resolution lives in vetted gem code (Loam::Configs).
|
|
10
|
+
# Loam::MfaCredential belongs to the person, who spans tenants — MFA is
|
|
11
|
+
# verified at login before any tenant is chosen.
|
|
12
|
+
TENANCY_ALLOWLIST = %w[ApplicationRecord User Loam::Tenant Loam::Config Loam::MfaCredential Loam::AuthAttempt].freeze
|
|
13
|
+
|
|
14
|
+
# Rails' own engine models (storage, rich text, jobs) are framework
|
|
15
|
+
# plumbing, not business data — they are out of scope for tenant linting.
|
|
16
|
+
FRAMEWORK_NAMESPACES = %w[ActiveStorage:: ActionText:: ActionMailbox:: SolidQueue:: SolidCache:: SolidCable::].freeze
|
|
17
|
+
|
|
18
|
+
test "every app model is tenant-scoped (inherits Loam::TenantRecord)" do
|
|
19
|
+
Rails.application.eager_load!
|
|
20
|
+
|
|
21
|
+
offenders = ActiveRecord::Base.descendants.reject do |model|
|
|
22
|
+
model.abstract_class? ||
|
|
23
|
+
model.name.nil? ||
|
|
24
|
+
TENANCY_ALLOWLIST.include?(model.name) ||
|
|
25
|
+
FRAMEWORK_NAMESPACES.any? { |ns| model.name.start_with?(ns) } ||
|
|
26
|
+
model <= Loam::TenantRecord
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
assert_empty offenders,
|
|
30
|
+
"These models are NOT tenant-scoped: #{offenders.map(&:name).join(', ')}. " \
|
|
31
|
+
"Business models must inherit Loam::TenantRecord (use `rails g loam:entity`). " \
|
|
32
|
+
"If a model is intentionally global, add it to TENANCY_ALLOWLIST here — in review, on purpose."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
test "touching a tenant-scoped model with no tenant context raises" do
|
|
36
|
+
Loam::Current.reset
|
|
37
|
+
|
|
38
|
+
assert_raises(Loam::MissingTenantError) { Loam::AuditRecord.count }
|
|
39
|
+
assert_raises(Loam::MissingTenantError) { Loam::Membership.first }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
test "nobody uses .unscoped outside vetted framework code" do
|
|
43
|
+
offenders = Dir[Rails.root.join("app/**/*.rb")].select do |file|
|
|
44
|
+
File.read(file).match?(/\bunscoped\b/)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
assert_empty offenders,
|
|
48
|
+
".unscoped found in: #{offenders.join(', ')}. It bypasses tenant isolation — remove it."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Agent harnesses read AGENTS.md into a context window and truncate what
|
|
52
|
+
# doesn't fit — silently. An overweight file doesn't warn, it just loses its
|
|
53
|
+
# tail, and the tail is where the invariants live.
|
|
54
|
+
AGENTS_MD_BUDGET_BYTES = 32_768
|
|
55
|
+
|
|
56
|
+
test "AGENTS.md stays inside its byte budget" do
|
|
57
|
+
path = Rails.root.join("AGENTS.md")
|
|
58
|
+
|
|
59
|
+
assert path.exist?, "AGENTS.md is missing from #{Rails.root} — it is the contract AI agents work against."
|
|
60
|
+
assert path.size <= AGENTS_MD_BUDGET_BYTES,
|
|
61
|
+
"AGENTS.md is #{path.size} bytes, over the #{AGENTS_MD_BUDGET_BYTES}-byte budget. " \
|
|
62
|
+
"Agent harnesses truncate instruction files without warning, so the tail — invariants, " \
|
|
63
|
+
"definition of done — would silently stop being read. Cut prose or move detail into " \
|
|
64
|
+
"docs/ and link it, rather than raising the budget."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
test "every Loam::FieldDefinition entity_type resolves to a class that uses Loam::CustomFields" do
|
|
68
|
+
Rails.application.eager_load!
|
|
69
|
+
|
|
70
|
+
offenders = Loam::FieldDefinition.unscoped.distinct.pluck(:entity_type).reject do |entity_type|
|
|
71
|
+
klass = entity_type.safe_constantize
|
|
72
|
+
klass && klass.include?(Loam::CustomFields)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
assert_empty offenders,
|
|
76
|
+
"These Loam::FieldDefinition entity_type values don't resolve to a class that " \
|
|
77
|
+
"`include Loam::CustomFields`: #{offenders.join(', ')}. Likely a typo when the field was created."
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Runs a CSV import in the background, advancing a Loam::ProgressJob so the admin
|
|
2
|
+
# watches it live (L-915). Carries tenant + actor explicitly, like every Loam job.
|
|
3
|
+
# The per-row summary (created/updated/failed + error rows) is stored on the
|
|
4
|
+
# ProgressJob's `result` for the summary screen.
|
|
5
|
+
class ImportJob < ApplicationJob
|
|
6
|
+
queue_as :default
|
|
7
|
+
|
|
8
|
+
def perform(entity_type:, csv:, mapping:, match_key:, tenant_id:, actor_id:)
|
|
9
|
+
tenant = Loam::Tenant.find(tenant_id)
|
|
10
|
+
actor = User.find_by(id: actor_id)
|
|
11
|
+
model = Loam::Import.allowed_model(entity_type)
|
|
12
|
+
|
|
13
|
+
Loam.as_tenant(tenant, actor: actor) do
|
|
14
|
+
total = [ CSV.parse(csv).size - 1, 0 ].max
|
|
15
|
+
progress = Loam::Progress.start(name: "Import #{entity_type}", total: total)
|
|
16
|
+
begin
|
|
17
|
+
result = Loam::Import.run(csv, model: model, mapping: mapping, actor: actor, match_key: match_key, progress: progress)
|
|
18
|
+
progress.update_columns(result: result.to_h)
|
|
19
|
+
progress.complete!
|
|
20
|
+
rescue StandardError => error
|
|
21
|
+
progress.fail!(error.message)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|