atomic_admin 2.2.0 → 2.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a5e0c43b3255ca7302f7bb6a855629bcda16adf72c4db4f0eeeb6384d8523bde
4
- data.tar.gz: c245120f9e4ed84752f57d886fdf3e5e00ccf6cf16f629759fc4e7a92fc3a283
3
+ metadata.gz: 06d8fa97576a29d8803456608d9081661ea90977713458d6386c9c55a8bb0356
4
+ data.tar.gz: e87935716e8a596854cb453f328fa30f9a0444b74954112993ee066269d3adfb
5
5
  SHA512:
6
- metadata.gz: 27503b634d4aea468dfad36e8cea8832b62a7dd34521b928559cb0ea462e16ece334874db721ffeab0bd7ca8290e82855b2d20d2d22cb40278fae525571c2a22
7
- data.tar.gz: 0ad42e55373b208dcfc3076bc070810ef26c82711cfcd3a085a89ca9b89571aff3412b83ee241ae2edb1216f2ee0ef3ff5a1a2bf6910c7c140aa353749e12707
6
+ metadata.gz: de38f6db4e56f1ed6c0c24d58ac995ab00538843573476c4866af0bd4b281bae251109eb102a4f46438fd165b9f8858a52cf62f270e192f1f508f70133391ca6
7
+ data.tar.gz: 2f1485e9a3c9ac1d61a10eb01bb04fab78d68a7ef8e1d40149796b36db0f071f3ff28b6bf9444974c1819349a6e6f77f3b95a6cea0d496558fb3568ba37f671a
@@ -0,0 +1,3 @@
1
+ module AtomicAdmin::Api::Admin::V1
2
+ RoleCapabilitiesController = AtomicAdmin::V1::RoleCapabilitiesController
3
+ end
@@ -7,12 +7,13 @@ module AtomicAdmin::V1
7
7
 
8
8
  def index
9
9
  @application_instances = ApplicationInstance.where(application_id: params[:application_id])
10
- @application_instances =
11
- if type == "paid"
12
- @application_instances.where.not(paid_at: nil)
13
- else
14
- @application_instances.where(paid_at: nil)
15
- end
10
+
11
+ case type
12
+ when "paid"
13
+ @application_instances = @application_instances.where.not(paid_at: nil)
14
+ when "evals"
15
+ @application_instances = @application_instances.where(paid_at: nil)
16
+ end
16
17
 
17
18
  @application_instances, meta = filter(@application_instances)
18
19
  @stats = get_stats_for_instances(@application_instances)
@@ -179,7 +180,7 @@ module AtomicAdmin::V1
179
180
  end
180
181
 
181
182
  def type
182
- params[:type] == "paid" ? "paid" : "evals"
183
+ params[:type]
183
184
  end
184
185
 
185
186
  def search
@@ -0,0 +1,188 @@
1
+ module AtomicAdmin::V1
2
+ # The API behind Skipper's `capabilities` interaction: a matrix of
3
+ # capabilities by roles, where each cell is what the role grants by default
4
+ # or an override row (ALLOW, DENY or PROHIBIT) at one scope. A scope is
5
+ # everywhere ("global"), one account ("account:<account id>") or one course
6
+ # ("course:<context id>").
7
+ #
8
+ # Rows are read and written through the host app's RoleCapability model
9
+ # (role, capability, mode, context_id, account_id) and Role model (name). The
10
+ # catalog, role defaults and account and course names come from the
11
+ # interaction's provider, an AtomicAdmin::RoleCapabilities::Provider
12
+ # subclass. See docs/interactions.md.
13
+ class RoleCapabilitiesController < AdminController
14
+ GLOBAL_SCOPE = "global".freeze
15
+ MODES = %w[ALLOW DENY PROHIBIT].freeze
16
+
17
+ around_action :switch_tenants
18
+
19
+ # The catalog, every role with what it grants by default, every override
20
+ # row for the catalog's capabilities, and the accounts and courses those
21
+ # rows are scoped to.
22
+ def show
23
+ rows = catalog_rows(RoleCapability.includes(:role).to_a)
24
+
25
+ render json: {
26
+ groups: provider.capability_groups,
27
+ capabilities: provider.capability_catalog,
28
+ modes: MODES,
29
+ tiers: provider.role_tiers,
30
+ roles: provider.listed_roles.map { |role| role_json(role) },
31
+ custom_roles: provider.custom_roles,
32
+ rows: rows.map { |row| row_json(row) },
33
+ scopes: scopes_json_for(rows),
34
+ notes: provider.notes,
35
+ }
36
+ end
37
+
38
+ # Replaces one role's override rows at one scope. A missing overrides key
39
+ # clears them.
40
+ #
41
+ # PATCH { role: { name: "canvas:Grade Viewer" }, scope: "account:12",
42
+ # overrides: { "reply_create" => "PROHIBIT", ... } }
43
+ def update
44
+ name = role_name_param
45
+ return render_unprocessable("role.name is required") if name.blank?
46
+
47
+ scope_key = params[:scope].presence || GLOBAL_SCOPE
48
+ context_id, account_id = parse_scope(scope_key)
49
+ if context_id.nil? && account_id.nil? && scope_key != GLOBAL_SCOPE
50
+ return render_unprocessable("scope must be global, account:<account id> or course:<context id>")
51
+ end
52
+ if context_id && provider.course_scopes([context_id]).empty?
53
+ return render_unprocessable("No course with context id #{context_id} has launched this tool")
54
+ end
55
+
56
+ overrides = params.key?(:overrides) ? overrides_param : {}
57
+ return render_unprocessable("overrides must map capability names to modes") if overrides.nil?
58
+
59
+ overrides = overrides.compact_blank
60
+ unknown = overrides.keys - provider.capability_names
61
+ return render_unprocessable("Unknown capabilities: #{unknown.join(', ')}") if unknown.any?
62
+
63
+ invalid_modes = overrides.values.uniq - MODES
64
+ return render_unprocessable("Unknown modes: #{invalid_modes.join(', ')}") if invalid_modes.any?
65
+
66
+ role = provider.role_for_update(name)
67
+ return render_unprocessable("Unknown role: #{name}") if role.nil?
68
+
69
+ provider.replace_overrides!(role, overrides, context_id:, account_id:)
70
+ saved = catalog_rows(RoleCapability.where(role:, context_id:, account_id:).includes(:role).to_a)
71
+
72
+ render json: {
73
+ role: role_json(role),
74
+ scope: scope_key,
75
+ overrides: saved.to_h { |row| [row.capability, row.mode] },
76
+ }
77
+ rescue ActiveRecord::RecordInvalid => e
78
+ render_unprocessable(e.record.errors.full_messages.join(", "))
79
+ end
80
+
81
+ # Accounts and courses matching a search, for the scope picker.
82
+ #
83
+ # GET ?q=law
84
+ def scopes
85
+ render json: { scopes: provider.search_scopes(params[:q].to_s.strip) }
86
+ end
87
+
88
+ private
89
+
90
+ def application_instance
91
+ @application_instance ||= ApplicationInstance.find(params[:application_instance_id])
92
+ end
93
+
94
+ def provider
95
+ @provider ||= interaction.provider_class.new(application_instance:)
96
+ end
97
+
98
+ # The routes are only drawn when a capabilities interaction is registered.
99
+ def interaction
100
+ AtomicAdmin.application_instance_interactions.for_type(:capabilities).first
101
+ end
102
+
103
+ def switch_tenants(&)
104
+ Apartment::Tenant.switch(application_instance.tenant, &)
105
+ end
106
+
107
+ def render_unprocessable(message)
108
+ render json: { error: message }, status: 422
109
+ end
110
+
111
+ # Only rows for the catalog's capabilities. Other rows stay stored but are
112
+ # not this editor's to show or save back.
113
+ def catalog_rows(rows)
114
+ rows.select { |row| provider.capability_names.include?(row.capability.to_s) }
115
+ end
116
+
117
+ def role_json(role)
118
+ tier = provider.role_tier(role)&.to_s
119
+ {
120
+ id: role.id,
121
+ name: role.name,
122
+ display_name: provider.role_display_name(role),
123
+ kind: provider.role_kind(role),
124
+ custom: provider.custom_role?(role),
125
+ tier:,
126
+ tier_label: provider.role_tiers.find { |t| t[:key].to_s == tier }&.dig(:label),
127
+ defaults: (provider.role_defaults(role).map(&:to_s) & provider.capability_names).sort,
128
+ }
129
+ end
130
+
131
+ def row_json(row)
132
+ { role: row.role.name, scope: scope_key_for(row), capability: row.capability.to_s, mode: row.mode }
133
+ end
134
+
135
+ def scope_key_for(row)
136
+ if row.context_id
137
+ "course:#{row.context_id}"
138
+ elsif row.account_id
139
+ "account:#{row.account_id}"
140
+ else
141
+ GLOBAL_SCOPE
142
+ end
143
+ end
144
+
145
+ # [context_id, account_id] for a scope key; [nil, nil] for global and for
146
+ # anything malformed (the caller tells the two apart). Context ids are
147
+ # opaque, so only the first ":" separates the type.
148
+ def parse_scope(key)
149
+ type, id = key.to_s.split(":", 2)
150
+ return [nil, nil] if id.blank?
151
+
152
+ case type
153
+ when "course" then [id, nil]
154
+ when "account" then provider.valid_account_id?(id) ? [nil, id] : [nil, nil]
155
+ else [nil, nil]
156
+ end
157
+ end
158
+
159
+ # Names for the scopes the rows use. A course that is gone keeps its rows
160
+ # listed under an unnamed scope.
161
+ def scopes_json_for(rows)
162
+ account_ids = rows.filter_map(&:account_id).uniq
163
+ context_ids = rows.filter_map(&:context_id).uniq
164
+ courses = provider.course_scopes(context_ids).index_by { |scope| scope[:id] }
165
+
166
+ provider.account_scopes(account_ids) +
167
+ context_ids.map { |id| courses[id] || provider.course_scope_json(id) }
168
+ end
169
+
170
+ # `role` must be an object with a name; anything else is treated as missing.
171
+ def role_name_param
172
+ role = params[:role]
173
+ role.respond_to?(:key?) ? role[:name].to_s.strip : ""
174
+ end
175
+
176
+ # { capability => mode } with string or nil values, or nil when the shape
177
+ # is wrong.
178
+ def overrides_param
179
+ overrides = params[:overrides]
180
+ return nil unless overrides.respond_to?(:to_unsafe_h)
181
+
182
+ overrides = overrides.to_unsafe_h
183
+ return nil unless overrides.values.all? { |mode| mode.nil? || mode.is_a?(String) }
184
+
185
+ overrides
186
+ end
187
+ end
188
+ end
data/config/routes.rb CHANGED
@@ -42,6 +42,14 @@ AtomicAdmin::Engine.routes.draw do
42
42
  end
43
43
  end
44
44
 
45
+ # Backs the `capabilities` interaction; the host supplies its
46
+ # provider. See AtomicAdmin::V1::RoleCapabilitiesController.
47
+ if AtomicAdmin.application_instance_interactions.for_type(:capabilities).any?
48
+ resource :role_capabilities, only: [:show, :update] do
49
+ get :scopes
50
+ end
51
+ end
52
+
45
53
  AtomicAdmin.application_instance_interactions.for_type(:resource).each do |interaction|
46
54
  controller_name = interaction.key.to_s.pluralize
47
55
  resources controller_name do
@@ -0,0 +1,15 @@
1
+ module AtomicAdmin::Interaction
2
+ # Skipper's Role Capabilities editor. `provider` is an
3
+ # AtomicAdmin::RoleCapabilities::Provider subclass, or its name; it is
4
+ # looked up on each request so it reloads in development.
5
+ class Capabilities < AtomicAdmin::Interaction::Base
6
+ def initialize(provider:, **kwargs)
7
+ super(**kwargs)
8
+ @provider = provider
9
+ end
10
+
11
+ def provider_class
12
+ @provider.is_a?(String) ? @provider.constantize : @provider
13
+ end
14
+ end
15
+ end
@@ -8,6 +8,7 @@ module AtomicAdmin::Interaction
8
8
  resource: AtomicAdmin::Interaction::Resource,
9
9
  launch: AtomicAdmin::Interaction::Launch,
10
10
  readonly: AtomicAdmin::Interaction::Readonly,
11
+ capabilities: AtomicAdmin::Interaction::Capabilities,
11
12
  }.freeze
12
13
 
13
14
  def initialize
@@ -4,6 +4,7 @@ require_relative 'interaction/analytics'
4
4
  require_relative 'interaction/resource'
5
5
  require_relative 'interaction/launch'
6
6
  require_relative 'interaction/readonly'
7
+ require_relative 'interaction/capabilities'
7
8
  require_relative 'interaction/manager'
8
9
 
9
10
  module AtomicAdmin::Interaction
@@ -0,0 +1,154 @@
1
+ module AtomicAdmin
2
+ module RoleCapabilities
3
+ # What a host app supplies for the `capabilities` interaction: the
4
+ # capability catalog, what each role grants by default, and how accounts
5
+ # and courses are named and found. Subclass it, implement
6
+ # `capability_catalog` and `role_defaults`, override any other hook, and
7
+ # name the class in the interaction's `provider:` option.
8
+ #
9
+ # AtomicAdmin::V1::RoleCapabilitiesController builds one per request with
10
+ # the application instance, inside that instance's tenant, and reads and
11
+ # writes the host's RoleCapability rows (role, capability, mode,
12
+ # context_id, account_id) itself.
13
+ class Provider
14
+ attr_reader :application_instance
15
+
16
+ def initialize(application_instance:)
17
+ @application_instance = application_instance
18
+ end
19
+
20
+ # Required. The capabilities this application instance can grant, in
21
+ # display order: [{ name:, group:, label:, description: }].
22
+ def capability_catalog
23
+ raise NotImplementedError, "#{self.class.name} must implement capability_catalog"
24
+ end
25
+
26
+ # Required. The capability names one role grants when it has no override
27
+ # rows.
28
+ def role_defaults(_role)
29
+ raise NotImplementedError, "#{self.class.name} must implement role_defaults"
30
+ end
31
+
32
+ # The catalog's groups in order: [{ key:, label: }].
33
+ def capability_groups
34
+ capability_catalog.map { |capability| capability[:group].to_s }.uniq.map do |group|
35
+ { key: group, label: group.humanize }
36
+ end
37
+ end
38
+
39
+ # The default sets roles fall into, highest first: [{ key:, label: }].
40
+ # Columns are ordered by tier.
41
+ def role_tiers
42
+ []
43
+ end
44
+
45
+ # The key of the role's tier, or nil for a role that grants nothing by
46
+ # default.
47
+ def role_tier(_role)
48
+ nil
49
+ end
50
+
51
+ # Every role that gets a column.
52
+ def listed_roles
53
+ Role.order(:name).to_a
54
+ end
55
+
56
+ # The column title for a role whose name is not a standard LTI role URI
57
+ # (Skipper shortens those itself).
58
+ def role_display_name(role)
59
+ role.name
60
+ end
61
+
62
+ # What kind of role it is ("Canvas custom role"), or nil to let Skipper
63
+ # work it out from an LTI role URI.
64
+ def role_kind(_role)
65
+ nil
66
+ end
67
+
68
+ # Whether the role is an LMS custom role added by name.
69
+ def custom_role?(_role)
70
+ false
71
+ end
72
+
73
+ # nil, or how roles that have not launched yet can be added by name:
74
+ # { label: "Canvas custom role", prefix: "canvas:", example: "Grade Viewer" }.
75
+ # Skipper stores a typed name as prefix + name.
76
+ def custom_roles
77
+ nil
78
+ end
79
+
80
+ # The role an update names, or nil to refuse it.
81
+ def role_for_update(name)
82
+ Role.find_by(name:)
83
+ end
84
+
85
+ # Whether an id from an "account:<id>" scope key can be an account id.
86
+ def valid_account_id?(account_id)
87
+ account_id.present?
88
+ end
89
+
90
+ # Scope entries for these account ids, in the same order. Build them
91
+ # with account_scope_json.
92
+ def account_scopes(account_ids)
93
+ account_ids.map { |id| account_scope_json(id) }
94
+ end
95
+
96
+ # Scope entries for the courses among these context ids that have
97
+ # launched the tool; leave the rest out. Build them with
98
+ # course_scope_json. Course scopes cannot be saved until this is
99
+ # implemented.
100
+ def course_scopes(_context_ids)
101
+ []
102
+ end
103
+
104
+ # Accounts and courses matching a search term.
105
+ def search_scopes(_term)
106
+ []
107
+ end
108
+
109
+ # Short notes shown under the matrix, such as how defaults combine.
110
+ def notes
111
+ []
112
+ end
113
+
114
+ # Replaces the role's rows for the catalog's capabilities at one scope.
115
+ # Rows for capabilities outside the catalog are left alone.
116
+ def replace_overrides!(role, overrides, context_id:, account_id:)
117
+ RoleCapability.transaction do
118
+ RoleCapability.where(role:, capability: capability_names, context_id:, account_id:).delete_all
119
+ overrides.each do |capability, mode|
120
+ RoleCapability.create!(role:, capability:, mode:, context_id:, account_id:)
121
+ end
122
+ end
123
+ end
124
+
125
+ def capability_names
126
+ @capability_names ||= capability_catalog.map { |capability| capability[:name].to_s }
127
+ end
128
+
129
+ # `path` names the account's ancestors and the account itself, root
130
+ # first.
131
+ def account_scope_json(account_id, label: nil, path: [])
132
+ {
133
+ key: "account:#{account_id}",
134
+ type: "account",
135
+ id: account_id.to_s,
136
+ label: label.presence || "Account #{account_id}",
137
+ path:,
138
+ }
139
+ end
140
+
141
+ # `account` is the course's account scope, from account_scope_json, if
142
+ # known.
143
+ def course_scope_json(context_id, label: nil, account: nil)
144
+ {
145
+ key: "course:#{context_id}",
146
+ type: "course",
147
+ id: context_id.to_s,
148
+ label: label.presence || "Course #{context_id}",
149
+ account:,
150
+ }
151
+ end
152
+ end
153
+ end
154
+ end
@@ -1,3 +1,3 @@
1
1
  module AtomicAdmin
2
- VERSION = "2.2.0".freeze
2
+ VERSION = "2.3.0".freeze
3
3
  end
data/lib/atomic_admin.rb CHANGED
@@ -3,6 +3,7 @@ require "atomic_admin/engine"
3
3
  require "atomic_admin/jwt_token"
4
4
  require "atomic_admin/schema"
5
5
  require "atomic_admin/interaction"
6
+ require "atomic_admin/role_capabilities/provider"
6
7
  require "atomic_admin/filtering"
7
8
 
8
9
  module AtomicAdmin
metadata CHANGED
@@ -1,15 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: atomic_admin
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.0
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Benoit
8
8
  - Sean Collings
9
- autorequire:
10
9
  bindir: bin
11
10
  cert_chain: []
12
- date: 2026-08-12 00:00:00.000000000 Z
11
+ date: 1980-01-02 00:00:00.000000000 Z
13
12
  dependencies:
14
13
  - !ruby/object:Gem::Dependency
15
14
  name: rails
@@ -113,6 +112,7 @@ files:
113
112
  - app/controllers/atomic_admin/api/admin/v1/applications_controller.rb
114
113
  - app/controllers/atomic_admin/api/admin/v1/lti_installs_controller.rb
115
114
  - app/controllers/atomic_admin/api/admin/v1/lti_platforms_controller.rb
115
+ - app/controllers/atomic_admin/api/admin/v1/role_capabilities_controller.rb
116
116
  - app/controllers/atomic_admin/api/admin/v1/sites_controller.rb
117
117
  - app/controllers/atomic_admin/api/admin/v1/tenant_client_id_strategies_controller.rb
118
118
  - app/controllers/atomic_admin/api/admin/v1/tenant_deployments_controller.rb
@@ -123,6 +123,7 @@ files:
123
123
  - app/controllers/atomic_admin/v1/applications_controller.rb
124
124
  - app/controllers/atomic_admin/v1/lti_installs_controller.rb
125
125
  - app/controllers/atomic_admin/v1/lti_platforms_controller.rb
126
+ - app/controllers/atomic_admin/v1/role_capabilities_controller.rb
126
127
  - app/controllers/atomic_admin/v1/sites_controller.rb
127
128
  - app/controllers/atomic_admin/v1/tenant_client_id_strategies_controller.rb
128
129
  - app/controllers/atomic_admin/v1/tenant_deployments_controller.rb
@@ -139,6 +140,7 @@ files:
139
140
  - lib/atomic_admin/interaction.rb
140
141
  - lib/atomic_admin/interaction/analytics.rb
141
142
  - lib/atomic_admin/interaction/base.rb
143
+ - lib/atomic_admin/interaction/capabilities.rb
142
144
  - lib/atomic_admin/interaction/json_form.rb
143
145
  - lib/atomic_admin/interaction/launch.rb
144
146
  - lib/atomic_admin/interaction/manager.rb
@@ -147,6 +149,7 @@ files:
147
149
  - lib/atomic_admin/jwt_token.rb
148
150
  - lib/atomic_admin/jwt_token/jwks_decoder.rb
149
151
  - lib/atomic_admin/jwt_token/secret_decoder.rb
152
+ - lib/atomic_admin/role_capabilities/provider.rb
150
153
  - lib/atomic_admin/schema.rb
151
154
  - lib/atomic_admin/schema/application_instance_configuration_schema.rb
152
155
  - lib/atomic_admin/schema/application_instance_create_schema.rb
@@ -165,7 +168,6 @@ metadata:
165
168
  homepage_uri: https://github.com/atomicjolt/atomic_admin/
166
169
  source_code_uri: https://github.com/atomicjolt/atomic_admin/
167
170
  changelog_uri: https://github.com/atomicjolt/atomic_admin/
168
- post_install_message:
169
171
  rdoc_options: []
170
172
  require_paths:
171
173
  - lib
@@ -180,8 +182,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
180
182
  - !ruby/object:Gem::Version
181
183
  version: '0'
182
184
  requirements: []
183
- rubygems_version: 3.4.10
184
- signing_key:
185
+ rubygems_version: 3.6.7
185
186
  specification_version: 4
186
187
  summary: Engine to provide apis that power the Atomic Jolt admin app
187
188
  test_files: []