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,194 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Loam
|
|
4
|
+
# Auto-generates an OpenAPI 3.1 document for the app's JSON API by
|
|
5
|
+
# INTROSPECTING what Loam already knows — the generated `Api::<Plural>Controller`s,
|
|
6
|
+
# each entity's columns/types, its exposed `FIELDS`, its custom fields, and that
|
|
7
|
+
# every endpoint is bearer-authenticated and tenant-scoped. No hand-written
|
|
8
|
+
# annotations, no external gem.
|
|
9
|
+
#
|
|
10
|
+
# Loam::OpenApi.document # => a Hash conforming to OpenAPI 3.1
|
|
11
|
+
# Loam::OpenApi.markdown # => a Markdown rendering
|
|
12
|
+
#
|
|
13
|
+
# The document describes the SHAPE of the API only — never tenant data. It is
|
|
14
|
+
# served at /admin/api_docs and exported by `bin/rails loam:openapi:export`.
|
|
15
|
+
module OpenApi
|
|
16
|
+
TYPE_MAP = {
|
|
17
|
+
"string" => { "type" => "string" }, "text" => { "type" => "string" },
|
|
18
|
+
"integer" => { "type" => "integer" }, "bigint" => { "type" => "integer" },
|
|
19
|
+
"float" => { "type" => "number" }, "decimal" => { "type" => "number" },
|
|
20
|
+
"boolean" => { "type" => "boolean" },
|
|
21
|
+
"date" => { "type" => "string", "format" => "date" },
|
|
22
|
+
"datetime" => { "type" => "string", "format" => "date-time" },
|
|
23
|
+
"json" => { "type" => "object" }, "jsonb" => { "type" => "object" }
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
TENANCY_NOTE = "Every endpoint is tenant-scoped by the bearer token: a caller only ever reads or " \
|
|
27
|
+
"writes data in the token's OWN tenant — cross-tenant access is impossible.".freeze
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def document
|
|
32
|
+
{
|
|
33
|
+
"openapi" => "3.1.0",
|
|
34
|
+
"info" => info,
|
|
35
|
+
"servers" => [ { "url" => "/api" } ],
|
|
36
|
+
"security" => [ { "bearerAuth" => [] } ],
|
|
37
|
+
"components" => { "securitySchemes" => security_schemes, "schemas" => schemas },
|
|
38
|
+
"paths" => paths,
|
|
39
|
+
"x-tenancy" => TENANCY_NOTE
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The Loam entities that have a generated JSON API controller. Anonymous
|
|
44
|
+
# subclasses (Class.new(Loam::TenantRecord), common in tests) have no name,
|
|
45
|
+
# so model_name would raise "Class name cannot be blank" — skip them: a
|
|
46
|
+
# nameless class has no controller or route to document anyway.
|
|
47
|
+
def api_entities
|
|
48
|
+
Rails.application.eager_load! if defined?(Rails) && Rails.respond_to?(:application)
|
|
49
|
+
Loam::TenantRecord.descendants
|
|
50
|
+
.reject { |model| model.name.blank? }
|
|
51
|
+
.select { |model| controller_for(model) }
|
|
52
|
+
.sort_by(&:name)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def controller_for(model)
|
|
56
|
+
# model_name.plural handles uncountables ("equipment"), unlike route_key
|
|
57
|
+
# (which becomes "equipment_index").
|
|
58
|
+
"Api::#{model.model_name.plural.camelize}Controller".safe_constantize
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def info
|
|
62
|
+
name = defined?(Rails) ? Rails.application.class.module_parent_name : "Loam"
|
|
63
|
+
{
|
|
64
|
+
"title" => "#{name} API",
|
|
65
|
+
"version" => "1.0.0",
|
|
66
|
+
"description" => "Auto-generated from the Loam entities. Bearer-token authenticated. #{TENANCY_NOTE}"
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def security_schemes
|
|
71
|
+
{ "bearerAuth" => { "type" => "http", "scheme" => "bearer",
|
|
72
|
+
"description" => "A Loam::ApiToken — identifies one user in one tenant." } }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def schemas
|
|
76
|
+
api_entities.each_with_object({}) do |model, out|
|
|
77
|
+
out[model.name] = entity_schema(model)
|
|
78
|
+
out["#{model.name}Input"] = input_schema(model)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def entity_schema(model)
|
|
83
|
+
props = { "id" => { "type" => "integer", "readOnly" => true } }
|
|
84
|
+
exposed_fields(model).each { |field| props[field] = column_schema(model, field) }
|
|
85
|
+
custom_field_names(model).each { |name| props[name] = { "type" => "string", "description" => "custom field" } }
|
|
86
|
+
%w[created_at updated_at].each { |ts| props[ts] = { "type" => "string", "format" => "date-time", "readOnly" => true } }
|
|
87
|
+
{ "type" => "object", "properties" => props }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Request body: the writable, declared fields only — never id/tenant_id/
|
|
91
|
+
# timestamps. Field-level write access is enforced per the token's role at
|
|
92
|
+
# runtime (Loam::Policy), which a structural schema can't express per-role;
|
|
93
|
+
# noted in the description rather than emitting a schema per role.
|
|
94
|
+
def input_schema(model)
|
|
95
|
+
props = {}
|
|
96
|
+
exposed_fields(model).each { |field| props[field] = column_schema(model, field) }
|
|
97
|
+
custom_field_names(model).each { |name| props[name] = { "type" => "string" } }
|
|
98
|
+
{
|
|
99
|
+
"type" => "object",
|
|
100
|
+
"properties" => props,
|
|
101
|
+
"description" => "Field-level write access applies per the token's role — a field the role may not write is ignored."
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def paths
|
|
106
|
+
api_entities.each_with_object({}) do |model, out|
|
|
107
|
+
plural = model.model_name.plural
|
|
108
|
+
ref = { "$ref" => "#/components/schemas/#{model.name}" }
|
|
109
|
+
input = { "$ref" => "#/components/schemas/#{model.name}Input" }
|
|
110
|
+
|
|
111
|
+
out["/#{plural}"] = {
|
|
112
|
+
"get" => operation("List #{plural}", "200" => array_response(ref)),
|
|
113
|
+
"post" => operation("Create a #{model.name}", body: input, "201" => object_response(ref), "422" => error_response("validation failed"))
|
|
114
|
+
}
|
|
115
|
+
out["/#{plural}/{id}"] = {
|
|
116
|
+
"parameters" => [ id_param ],
|
|
117
|
+
"get" => operation("Fetch a #{model.name}", "200" => object_response(ref), "404" => error_response("not found")),
|
|
118
|
+
"patch" => operation("Update a #{model.name}", body: input, "200" => object_response(ref), "422" => error_response("validation failed"), "404" => error_response("not found")),
|
|
119
|
+
"delete" => operation("Soft-delete a #{model.name}", "204" => { "description" => "deleted" }, "404" => error_response("not found"))
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# ---- markdown ----
|
|
125
|
+
|
|
126
|
+
def markdown(doc = document)
|
|
127
|
+
lines = [ "# #{doc['info']['title']}", "", doc["info"]["description"], "", "**Auth:** bearer token. **Tenancy:** #{doc['x-tenancy']}", "" ]
|
|
128
|
+
doc["paths"].sort.each do |path, ops|
|
|
129
|
+
ops.each do |method, op|
|
|
130
|
+
next unless op.is_a?(Hash) && op["summary"]
|
|
131
|
+
lines << "## `#{method.upcase} /api#{path}` — #{op['summary']}"
|
|
132
|
+
lines << "Requires a bearer token. Responses: #{op['responses'].keys.join(', ')}."
|
|
133
|
+
lines << ""
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
lines.join("\n")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# ---- internals ----
|
|
140
|
+
|
|
141
|
+
def exposed_fields(model)
|
|
142
|
+
controller = controller_for(model)
|
|
143
|
+
return controller::FIELDS.map(&:to_s) if controller.const_defined?(:FIELDS)
|
|
144
|
+
|
|
145
|
+
# No declared FIELDS: fall back to columns, but NEVER surface an encrypted
|
|
146
|
+
# column or its blind-index `_hash` (same exclusion as Loam::Export).
|
|
147
|
+
encrypted = model.respond_to?(:loam_encrypted_attributes) ? model.loam_encrypted_attributes : []
|
|
148
|
+
blind = model.respond_to?(:loam_searchable_encrypted_attributes) ? model.loam_searchable_encrypted_attributes.map { |a| "#{a}_hash" } : []
|
|
149
|
+
model.column_names - plumbing(model) - encrypted - blind
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def plumbing(model)
|
|
153
|
+
%w[id tenant_id created_at updated_at lock_version deleted_at custom_fields]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def column_schema(model, field)
|
|
157
|
+
column = model.columns_hash[field.to_s]
|
|
158
|
+
return { "type" => "string" } unless column # a custom field or virtual
|
|
159
|
+
|
|
160
|
+
TYPE_MAP.fetch(column.type.to_s, { "type" => "string" }).dup
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def custom_field_names(model)
|
|
164
|
+
return [] unless model.respond_to?(:custom_field_definitions) && Loam::Current.tenant
|
|
165
|
+
|
|
166
|
+
model.custom_field_definitions.map(&:name)
|
|
167
|
+
rescue StandardError
|
|
168
|
+
[]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def operation(summary, body: nil, **responses)
|
|
172
|
+
responses = { "401" => error_response("missing or invalid token") }.merge(responses)
|
|
173
|
+
op = { "summary" => summary, "responses" => responses }
|
|
174
|
+
if body
|
|
175
|
+
op["requestBody"] = { "required" => true, "content" => { "application/json" => { "schema" => body } } }
|
|
176
|
+
end
|
|
177
|
+
op
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def object_response(ref) = { "description" => "ok", "content" => { "application/json" => { "schema" => ref } } }
|
|
181
|
+
|
|
182
|
+
def array_response(ref)
|
|
183
|
+
{ "description" => "ok", "content" => { "application/json" => { "schema" => { "type" => "array", "items" => ref } } } }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def error_response(desc)
|
|
187
|
+
{ "description" => desc, "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "error" => { "type" => "string" } } } } } }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def id_param
|
|
191
|
+
{ "name" => "id", "in" => "path", "required" => true, "schema" => { "type" => "integer" } }
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Disable or replace an entry in one of Loam's OWN keyed registries from an
|
|
3
|
+
# initializer — customization without forking the gem, and without
|
|
4
|
+
# monkeypatching. This is deliberately SMALL: Rails already handles structural
|
|
5
|
+
# overriding (shadow a view/controller by path, prepend a module). Overrides
|
|
6
|
+
# only covers the in-gem keyed registries, where path-shadowing doesn't reach:
|
|
7
|
+
#
|
|
8
|
+
# Loam::Overrides.disable(:widgets, "open_progress") # drop a built-in widget
|
|
9
|
+
# Loam::Overrides.replace(:widgets, "audit_recent") { |actor| { kind: "count", value: 0 } }
|
|
10
|
+
# Loam::Overrides.disable(:broadcast_events, "loam.progress.") # stop pushing an event over SSE
|
|
11
|
+
#
|
|
12
|
+
# THE VALUE-ADD over raw monkeypatching: a stale override (a typo, or an entry
|
|
13
|
+
# that no longer exists) is caught at boot by `check!` and warned about, so a
|
|
14
|
+
# silently-ineffective override is visible instead of a mystery.
|
|
15
|
+
#
|
|
16
|
+
# Registries with a keyed API — :widgets, :broadcast_events — honor disable AND
|
|
17
|
+
# replace. Seams that are a single swappable object (Search.driver,
|
|
18
|
+
# EventStream.broadcaster) are replaced by assigning them directly; Overrides
|
|
19
|
+
# doesn't wrap those. Controllers/views/routes are NOT here — that's Rails'
|
|
20
|
+
# path-shadowing job (see docs).
|
|
21
|
+
module Overrides
|
|
22
|
+
DISABLED = :__loam_disabled__
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def disable(registry, key)
|
|
26
|
+
store[registry.to_sym][key.to_s] = DISABLED
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def replace(registry, key, &block)
|
|
30
|
+
raise ArgumentError, "replace needs a block" unless block
|
|
31
|
+
|
|
32
|
+
store[registry.to_sym][key.to_s] = block
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def disabled?(registry, key)
|
|
36
|
+
store[registry.to_sym][key.to_s] == DISABLED
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The replacement block for a key, or nil (also nil when disabled).
|
|
40
|
+
def replacement(registry, key)
|
|
41
|
+
value = store[registry.to_sym][key.to_s]
|
|
42
|
+
value unless value == DISABLED || value.nil?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def entries(registry)
|
|
46
|
+
store[registry.to_sym]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def all
|
|
50
|
+
store
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reset!
|
|
54
|
+
@store = Hash.new { |hash, key| hash[key] = {} }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# For tests: capture and restore the process-global override state so a
|
|
58
|
+
# test's overrides don't leak (and the app's boot-time overrides survive).
|
|
59
|
+
def snapshot
|
|
60
|
+
store.each_with_object({}) { |(registry, keys), out| out[registry] = keys.dup }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def restore(snapshot)
|
|
64
|
+
reset!
|
|
65
|
+
snapshot.each { |registry, keys| store[registry].merge!(keys) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Overrides whose key isn't present in the live registry — a typo or a
|
|
69
|
+
# removed entry. Only registries we can introspect are checked; others are
|
|
70
|
+
# skipped (returned as safe). Returns [[registry, key], ...].
|
|
71
|
+
def stale
|
|
72
|
+
store.flat_map do |registry, keys|
|
|
73
|
+
known = known_keys(registry)
|
|
74
|
+
next [] if known.nil?
|
|
75
|
+
|
|
76
|
+
keys.keys.reject { |key| known.include?(key) }.map { |key| [ registry, key ] }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Warn (once, at boot) about every stale override, and return them.
|
|
81
|
+
def check!
|
|
82
|
+
found = stale
|
|
83
|
+
found.each do |registry, key|
|
|
84
|
+
message = "[loam] stale override: #{registry} has no entry #{key.inspect} to disable/replace — the override is doing nothing."
|
|
85
|
+
logger ? logger.warn(message) : warn(message)
|
|
86
|
+
end
|
|
87
|
+
found
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def store
|
|
93
|
+
@store ||= Hash.new { |hash, key| hash[key] = {} }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The live keys of an introspectable registry, or nil when we can't tell.
|
|
97
|
+
def known_keys(registry)
|
|
98
|
+
case registry.to_sym
|
|
99
|
+
when :widgets then Loam::Widgets.keys
|
|
100
|
+
when :broadcast_events then Loam.broadcast_events
|
|
101
|
+
when :scheduler then defined?(Loam::Scheduler) ? Loam::Scheduler.registered.map { |d| d[:key] } : nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def logger
|
|
106
|
+
Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Staging and reviewing gated mutations (see Loam::PendingAction).
|
|
3
|
+
#
|
|
4
|
+
# pending = Loam::PendingActions.stage(
|
|
5
|
+
# summary: "Raise the excavator's daily rate to 1100",
|
|
6
|
+
# on: equipment, action: :update, changes: { daily_rate: 1100 }
|
|
7
|
+
# )
|
|
8
|
+
# pending.preview # => { "daily_rate" => { "from" => 950, "to" => 1100 } }
|
|
9
|
+
# pending.approve!(by: manager) # role-gated; executes the change
|
|
10
|
+
#
|
|
11
|
+
# `stage` NEVER touches the target — it only records the intent. Loam does not
|
|
12
|
+
# intercept Active Record globally (that would be fragile and out of scope);
|
|
13
|
+
# this is the primitive a confirm-mode write path calls instead of saving.
|
|
14
|
+
module PendingActions
|
|
15
|
+
class << self
|
|
16
|
+
def stage(summary:, on:, action:, changes: {}, idempotency_key: nil, actor: Loam::Current.actor)
|
|
17
|
+
target_type, target_id = resolve_target(on)
|
|
18
|
+
changes = changes.transform_keys(&:to_s)
|
|
19
|
+
key = idempotency_key || compute_key(target_type, target_id, action, changes)
|
|
20
|
+
|
|
21
|
+
# The same proposal staged twice collapses to one row — but only a still
|
|
22
|
+
# PENDING one. A rejected or executed proposal with this key may be
|
|
23
|
+
# re-staged as a fresh pending row (the partial index allows the coexist).
|
|
24
|
+
existing = Loam::PendingAction.pending.find_by(idempotency_key: key)
|
|
25
|
+
return existing if existing
|
|
26
|
+
|
|
27
|
+
Loam::PendingAction.create!(
|
|
28
|
+
actor_id: actor&.id,
|
|
29
|
+
action_type: action.to_s,
|
|
30
|
+
target_type: target_type,
|
|
31
|
+
target_id: target_id,
|
|
32
|
+
changeset: changes,
|
|
33
|
+
summary: summary,
|
|
34
|
+
idempotency_key: key
|
|
35
|
+
)
|
|
36
|
+
rescue ActiveRecord::RecordNotUnique
|
|
37
|
+
# Lost a concurrent double-stage race — return the pending row that won.
|
|
38
|
+
Loam::PendingAction.pending.find_by!(idempotency_key: key)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# A class stages a create (no target id yet); a record stages a change to
|
|
44
|
+
# itself.
|
|
45
|
+
def resolve_target(on)
|
|
46
|
+
on.is_a?(Class) ? [ on.name, nil ] : [ on.class.name, on.id ]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A KEYED (per-tenant HMAC), not a plain, digest of the proposal: a raw
|
|
50
|
+
# SHA-256 of a low-entropy value (a tax id, an SSN) in an indexed column
|
|
51
|
+
# would be a brute-force oracle from a DB dump. Reuses L-901's blind index.
|
|
52
|
+
# Determinism relies on `changes` insertion order — the same caller yields
|
|
53
|
+
# the same key; a reordered hash is treated as a different proposal.
|
|
54
|
+
def compute_key(target_type, target_id, action, changes)
|
|
55
|
+
payload = [ target_type, target_id, action, changes.to_json ].join("|")
|
|
56
|
+
Loam::Encryption.blind_index_scoped(payload, "tenant/#{Loam.tenant!.id}")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Feature-string permissions with wildcards (L-705) — a fine-grained capability
|
|
3
|
+
# layer that sits UNDER the coarse role. Roles answer "manager or clerk";
|
|
4
|
+
# permissions answer "may this role do `equipment.edit`" without inventing a new
|
|
5
|
+
# role for every distinction. Orthogonal to Loam::Policy (which gates field-level
|
|
6
|
+
# writes on a record) and to Loam::Features (a per-tenant capability switch).
|
|
7
|
+
#
|
|
8
|
+
# Declared once, in the initializer (like broadcast_events / scheduler defaults):
|
|
9
|
+
#
|
|
10
|
+
# Loam::Permissions.configure do
|
|
11
|
+
# role :admin, allow: "*" # everything
|
|
12
|
+
# role :manager, allow: %w[equipment.* damage_report.* billing.read]
|
|
13
|
+
# role :clerk, allow: %w[equipment.read damage_report.create]
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Loam::Permissions.allow?(:clerk, "equipment.read") # => true
|
|
17
|
+
# Loam::Permissions.allow?(:clerk, "equipment.edit") # => false
|
|
18
|
+
# Loam.can?("equipment.edit") # for the current actor's role
|
|
19
|
+
#
|
|
20
|
+
# DENY BY DEFAULT: a role with no matching grant (or no grants at all) is denied.
|
|
21
|
+
#
|
|
22
|
+
# Wildcards: `*` grants everything; a trailing `.*` is a prefix ("equipment.*"
|
|
23
|
+
# matches "equipment.read" and "equipment.anything.deep", and "equipment"
|
|
24
|
+
# itself); anything else is an exact match. (Deliberately not a full glob —
|
|
25
|
+
# prefix + all covers the real cases; a mid-string `*` is not special.)
|
|
26
|
+
module Permissions
|
|
27
|
+
class << self
|
|
28
|
+
def configure(&block)
|
|
29
|
+
DSL.new.instance_eval(&block)
|
|
30
|
+
registry
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Grant one role a pattern or list of patterns (additive).
|
|
34
|
+
def role(name, allow:)
|
|
35
|
+
registry[name.to_s] ||= []
|
|
36
|
+
registry[name.to_s].concat(Array(allow).map(&:to_s)).uniq!
|
|
37
|
+
registry[name.to_s]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def granted(role) = registry[role.to_s] || []
|
|
41
|
+
|
|
42
|
+
def allow?(role, permission)
|
|
43
|
+
return false if role.nil?
|
|
44
|
+
|
|
45
|
+
granted(role).any? { |pattern| matches?(pattern, permission.to_s) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# THE wildcard rule, in one place.
|
|
49
|
+
def matches?(pattern, permission)
|
|
50
|
+
pattern = pattern.to_s
|
|
51
|
+
return true if pattern == "*"
|
|
52
|
+
|
|
53
|
+
if pattern.end_with?(".*")
|
|
54
|
+
prefix = pattern[0..-2] # "equipment." (keep the dot)
|
|
55
|
+
permission == pattern[0..-3] || permission.start_with?(prefix)
|
|
56
|
+
else
|
|
57
|
+
permission == pattern
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def reset!
|
|
62
|
+
@registry = {}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def registry
|
|
68
|
+
@registry ||= {}
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Tiny DSL so `configure { role ... }` reads well without a receiver.
|
|
73
|
+
class DSL
|
|
74
|
+
def role(name, allow:)
|
|
75
|
+
Loam::Permissions.role(name, allow: allow)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Reading the saved views (Loam::Perspective) a user may see for an entity.
|
|
3
|
+
#
|
|
4
|
+
# Loam::Perspectives.visible_to("Equipment", user: current_actor) # pick list
|
|
5
|
+
# Loam::Perspectives.default_for("Equipment", user: current_actor) # the applicable default
|
|
6
|
+
# Loam::Perspectives.resolve("Equipment", user:, id: params[:perspective_id])
|
|
7
|
+
#
|
|
8
|
+
# Every query is tenant-scoped by Loam::Perspective. The membership role used
|
|
9
|
+
# for role-shared views is the user's role in the CURRENT tenant.
|
|
10
|
+
module Perspectives
|
|
11
|
+
class << self
|
|
12
|
+
# The views this user may see for an entity: their own private ones, the
|
|
13
|
+
# role-shared ones matching their membership role, and the tenant-wide
|
|
14
|
+
# ones. Default(s) first, then by name. Named `visible_to` rather than
|
|
15
|
+
# `for` so it does not read like Loam::Policy.for (which builds a policy).
|
|
16
|
+
def visible_to(entity_type, user:)
|
|
17
|
+
base = Loam::Perspective.where(entity_type: entity_type.to_s)
|
|
18
|
+
role = membership_role(user)
|
|
19
|
+
|
|
20
|
+
# Build the OR chain on UNORDERED relations — `.or` refuses to combine
|
|
21
|
+
# relations that carry an order — then order once at the end.
|
|
22
|
+
visible = base.where(visibility: "private", owner_id: user&.id)
|
|
23
|
+
.or(base.where(visibility: "tenant"))
|
|
24
|
+
visible = visible.or(base.where(visibility: "role", role: role)) if role
|
|
25
|
+
|
|
26
|
+
visible.order(is_default: :desc, name: :asc)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The applicable default, most specific audience first: the user's own
|
|
30
|
+
# private default, else a role default, else a tenant default, else nil.
|
|
31
|
+
def default_for(entity_type, user:)
|
|
32
|
+
defaults = visible_to(entity_type, user: user).select(&:is_default?)
|
|
33
|
+
defaults.min_by { |perspective| VISIBILITY_PRIORITY.fetch(perspective.visibility, 9) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The view to apply for an index request: the one explicitly picked (if the
|
|
37
|
+
# user may see it), otherwise the default. nil means "no saved view".
|
|
38
|
+
def resolve(entity_type, user:, id: nil)
|
|
39
|
+
# "none" is the picker's explicit "show everything" — it must bypass the
|
|
40
|
+
# default, or a tenant default would be inescapable from the UI.
|
|
41
|
+
return nil if id == "none"
|
|
42
|
+
|
|
43
|
+
if id.present?
|
|
44
|
+
picked = visible_to(entity_type, user: user).find_by(id: id)
|
|
45
|
+
return picked if picked
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
default_for(entity_type, user: user)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
VISIBILITY_PRIORITY = { "private" => 0, "role" => 1, "tenant" => 2 }.freeze
|
|
54
|
+
|
|
55
|
+
def membership_role(user)
|
|
56
|
+
return nil unless user
|
|
57
|
+
|
|
58
|
+
Loam::Membership.find_by(user_id: user.id)&.role
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/loam/policy.rb
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Base policy. One policy class per entity, one instance per (actor, record)
|
|
3
|
+
# pair. Action checks (read?/create?/update?/destroy?) default to "any member
|
|
4
|
+
# of the current tenant"; field-level write access is declared, not coded:
|
|
5
|
+
#
|
|
6
|
+
# class EquipmentPolicy < Loam::Policy
|
|
7
|
+
# field :daily_rate, writable: [:manager]
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# Roles come from Loam::Membership (actor + current tenant -> role).
|
|
11
|
+
class Policy
|
|
12
|
+
class << self
|
|
13
|
+
def field_rules
|
|
14
|
+
@field_rules ||= {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def field(name, writable: nil, readable: nil)
|
|
18
|
+
field_rules[name.to_sym] = { writable: writable, readable: readable }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def for(record)
|
|
22
|
+
policy_class = "#{record.class.name}Policy".safe_constantize
|
|
23
|
+
raise Error, "No policy defined for #{record.class.name} (expected #{record.class.name}Policy)" unless policy_class
|
|
24
|
+
|
|
25
|
+
policy_class.new(Loam::Current.actor, record)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :actor, :record
|
|
30
|
+
|
|
31
|
+
def initialize(actor, record)
|
|
32
|
+
@actor = actor
|
|
33
|
+
@record = record
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def role
|
|
37
|
+
return nil unless actor
|
|
38
|
+
|
|
39
|
+
@role ||= Loam::Membership.find_by(user_id: actor.id)&.role&.to_sym
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def member? = role.present?
|
|
43
|
+
|
|
44
|
+
def read? = member?
|
|
45
|
+
def create? = member?
|
|
46
|
+
def update? = member?
|
|
47
|
+
def destroy? = member?
|
|
48
|
+
|
|
49
|
+
# Field-level check: fields without a declared rule are writable by any
|
|
50
|
+
# member; fields with `writable:` only by the listed roles.
|
|
51
|
+
def writable?(field_name)
|
|
52
|
+
rule = self.class.field_rules[field_name.to_sym]
|
|
53
|
+
return member? if rule.nil? || rule[:writable].nil?
|
|
54
|
+
|
|
55
|
+
Array(rule[:writable]).map(&:to_sym).include?(role)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def permitted_fields(field_names)
|
|
59
|
+
field_names.select { |f| writable?(f) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Field-level READ check (used by CSV export): a field with no `readable:`
|
|
63
|
+
# rule is readable by any member; with one, only by the listed roles.
|
|
64
|
+
def readable?(field_name)
|
|
65
|
+
rule = self.class.field_rules[field_name.to_sym]
|
|
66
|
+
return member? if rule.nil? || rule[:readable].nil?
|
|
67
|
+
|
|
68
|
+
Array(rule[:readable]).map(&:to_sym).include?(role)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def readable_fields(field_names)
|
|
72
|
+
field_names.select { |f| readable?(f) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Same semantics as the static `field writable:` declaration, but for a
|
|
76
|
+
# runtime Loam::FieldDefinition (see Loam::CustomFields): no
|
|
77
|
+
# writable_roles recorded means any member may write it.
|
|
78
|
+
def custom_field_writable?(field_name)
|
|
79
|
+
return false unless member?
|
|
80
|
+
|
|
81
|
+
definition = record.class.custom_field_definitions.find_by(name: field_name.to_s)
|
|
82
|
+
return false unless definition
|
|
83
|
+
|
|
84
|
+
definition.writable_roles.blank? || definition.writable_roles.map(&:to_sym).include?(role)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def permitted_custom_fields(field_names)
|
|
88
|
+
field_names.select { |f| custom_field_writable?(f) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Read counterpart of custom_field_writable?: a runtime field with no
|
|
92
|
+
# readable_roles is readable by any member; with some, only by those roles.
|
|
93
|
+
def custom_field_readable?(field_name)
|
|
94
|
+
return false unless member?
|
|
95
|
+
|
|
96
|
+
definition = record.class.custom_field_definitions.find_by(name: field_name.to_s)
|
|
97
|
+
return false unless definition
|
|
98
|
+
|
|
99
|
+
definition.readable_by?(role)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def readable_custom_fields(field_names)
|
|
103
|
+
field_names.select { |f| custom_field_readable?(f) }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Entry point for progress tracking. Start a job, then drive it through the
|
|
3
|
+
# returned Loam::ProgressJob:
|
|
4
|
+
#
|
|
5
|
+
# progress = Loam::Progress.start(name: "Reindex", total: equipment.count)
|
|
6
|
+
# equipment.find_each do |record|
|
|
7
|
+
# break if progress.cancelled? # cooperative cancel
|
|
8
|
+
# do_work(record)
|
|
9
|
+
# progress.advance # throttled SSE push
|
|
10
|
+
# end
|
|
11
|
+
# progress.complete!
|
|
12
|
+
#
|
|
13
|
+
# The job is created in the current tenant (and stamped with the current
|
|
14
|
+
# actor), so it is only visible and streamable there. In a background job,
|
|
15
|
+
# establish the tenant with Loam.as_tenant first, like the webhook/digest jobs.
|
|
16
|
+
module Progress
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def start(name:, total:, key: nil)
|
|
20
|
+
Loam::ProgressJob.create!(
|
|
21
|
+
key: key,
|
|
22
|
+
name: name,
|
|
23
|
+
total: total.to_i,
|
|
24
|
+
completed: 0,
|
|
25
|
+
status: "running",
|
|
26
|
+
actor_id: Loam.actor&.id,
|
|
27
|
+
started_at: Time.current
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|