diff --git a/Gemfile.lock b/Gemfile.lock index 5899690..fa85f88 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,13 +3,13 @@ PATH specs: decidim-rest_full (0.0.2) api-pagination (~> 6.0) + cancancan decidim-admin (>= 0.28, < 0.30) decidim-comments (>= 0.28, < 0.30) decidim-core (>= 0.28, < 0.30) doorkeeper jsonapi-serializer rswag-api - rswag-ui GEM remote: https://rubygems.org/ @@ -108,6 +108,7 @@ GEM activesupport (>= 3.0.0) uniform_notifier (~> 1.11) byebug (11.1.3) + cancancan (3.6.1) capybara (3.40.0) addressable matrix @@ -677,9 +678,6 @@ GEM json-schema (>= 2.2, < 6.0) railties (>= 5.2, < 8.0) rspec-core (>= 2.14) - rswag-ui (2.15.0) - actionpack (>= 5.2, < 8.0) - railties (>= 5.2, < 8.0) rubocop (1.65.1) json (~> 2.3) language_server-protocol (>= 3.17.0) diff --git a/Rakefile b/Rakefile index 2d9116c..472c000 100644 --- a/Rakefile +++ b/Rakefile @@ -6,7 +6,7 @@ require "rspec/core/rake_task" def install_module(path) Dir.chdir(path) do system("bundle check || bundle install") - # system("bundle exec rake decidim_rest_full:install:migrations") + system("bundle exec rake decidim_rest_full:install:migrations") system("bundle exec rails db:migrate") end end diff --git a/app/controllers/decidim/api/rest_full/application_controller.rb b/app/controllers/decidim/api/rest_full/application_controller.rb index 4cf0b5b..d4a006e 100644 --- a/app/controllers/decidim/api/rest_full/application_controller.rb +++ b/app/controllers/decidim/api/rest_full/application_controller.rb @@ -5,6 +5,7 @@ module Api module RestFull class ApplicationController < ActionController::API include Decidim::RestFull::ApiException::Handler + delegate :can?, :cannot?, :authorize!, to: :ability protected @@ -59,6 +60,10 @@ def available_locales private + def ability + @ability ||= Decidim::RestFull::Ability.from_doorkeeper_token(doorkeeper_token) + end + def populate_params @populate_params ||= if params[:populate].is_a?(String) params[:populate].split(",").map(&:to_sym) diff --git a/app/controllers/decidim/api/rest_full/system/organizations_controller.rb b/app/controllers/decidim/api/rest_full/system/organizations_controller.rb index 22d6884..7fd9662 100644 --- a/app/controllers/decidim/api/rest_full/system/organizations_controller.rb +++ b/app/controllers/decidim/api/rest_full/system/organizations_controller.rb @@ -6,39 +6,46 @@ module Api module RestFull module System class OrganizationsController < ApplicationController - before_action { doorkeeper_authorize! :system } + before_action do + doorkeeper_authorize! :system + authorize! :read, ::Decidim::Organization + end # List all organizations def index - # Extract only the populated fields - allowed_fields = OrganizationSerializer.db_fields - only_fields = populated_fields([], allowed_fields) - # Fetch organizations and paginate - organizations = paginate(Decidim::Organization.select(*only_fields)) + organizations = paginate(collection) # Render the response - render json: OrganizationSerializer.new( - organizations, - params: { only: only_fields, locales: available_locales }, - fields: { organization: only_fields.push(:meta) } - ).serializable_hash + render json: serializable_hash(organizations) end # Show a single organization def show - # Extract only the populated fields - only_fields = populated_fields(OrganizationSerializer.db_fields, OrganizationSerializer.db_fields) - # Find the organization by ID - organization = Decidim::Organization.find(params[:id]) - + organization = collection.find(params[:id]) # Render the response - render json: OrganizationSerializer.new( - organization, - params: { only: only_fields, locales: available_locales }, - fields: { organization: only_fields.map(&:to_sym) } + render json: serializable_hash(organization) + end + + private + + def serializable_hash(resource) + OrganizationSerializer.new( + resource, + params: { locales: available_locales } ).serializable_hash end + + def collection + Decidim::Organization.select( + :id, + :name, + :secondary_hosts, + :host, + :created_at, + :updated_at + ) + end end end end diff --git a/app/controllers/decidim/api/rest_full/system/users_controller.rb b/app/controllers/decidim/api/rest_full/system/users_controller.rb index 4dd620f..52fe190 100644 --- a/app/controllers/decidim/api/rest_full/system/users_controller.rb +++ b/app/controllers/decidim/api/rest_full/system/users_controller.rb @@ -5,7 +5,10 @@ module Api module RestFull module System class UsersController < ApplicationController - before_action { doorkeeper_authorize! :system } + before_action do + doorkeeper_authorize! :system + authorize! :read, ::Decidim::User + end # List all users def index diff --git a/app/controllers/decidim/rest_full/system/api_clients_controller.rb b/app/controllers/decidim/rest_full/system/api_clients_controller.rb index 8b17250..7c40625 100644 --- a/app/controllers/decidim/rest_full/system/api_clients_controller.rb +++ b/app/controllers/decidim/rest_full/system/api_clients_controller.rb @@ -31,6 +31,7 @@ def new def edit @api_client = collection.find(params[:id]) @form = form(ApiClientForm).from_model(@api_client) + @perm_form = form(ApiPermissions).from_model(@api_client) end def create diff --git a/app/controllers/decidim/rest_full/system/permissions_controller.rb b/app/controllers/decidim/rest_full/system/permissions_controller.rb new file mode 100644 index 0000000..0842dde --- /dev/null +++ b/app/controllers/decidim/rest_full/system/permissions_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Decidim + module RestFull + module System + class PermissionsController < Decidim::System::ApplicationController + helper Decidim::Admin::AttributesDisplayHelper + helper Decidim::Core::Engine.routes.url_helpers + helper_method :destroy_admin_session_path + + def core_engine_routes + Decidim::Core::Engine.routes.url_helpers + end + + def create + @form = form(ApiPermissions).from_params(params) + api_client = Decidim::RestFull::ApiClient.find(@form.api_client_id) + api_client.permissions = @form.permissions.map do |perm_string| + api_client.permissions.build(permission: perm_string) + end + api_client.save! + redirect_to core_engine_routes.edit_system_api_client_path(api_client) + end + end + end + end +end diff --git a/app/forms/decidim/rest_full/api_permissions.rb b/app/forms/decidim/rest_full/api_permissions.rb new file mode 100644 index 0000000..c3cfc9a --- /dev/null +++ b/app/forms/decidim/rest_full/api_permissions.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Decidim + module RestFull + # The form that validates the data to construct a valid OAuthApplication. + class ApiPermissions < Decidim::Form + mimic :system_api_client + attribute :permissions, [String] + attribute :api_client_id, Integer + + def organization + current_organization || Decidim::Organization.find_by(id: decidim_organization_id) + end + end + end +end diff --git a/app/models/decidim/rest_full/ability.rb b/app/models/decidim/rest_full/ability.rb new file mode 100644 index 0000000..2358ed1 --- /dev/null +++ b/app/models/decidim/rest_full/ability.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "cancan" + +module Decidim + module RestFull + class Ability + include ::CanCan::Ability + attr_reader :api_client, :permissions + + def initialize(api_client) + return unless api_client + + @api_client = api_client + @permissions = api_client.permission_strings + + can :impersonate, Decidim::RestFull::ApiClient if permissions.include? "oauth.impersonate" + can :login, Decidim::RestFull::ApiClient if permissions.include? "oauth.login" + # Switch scopes and compose permissions + scopes = api_client.scopes.to_a + perms_for_public if scopes.include? "public" + perms_for_system if scopes.include? "system" + perms_for_proposals if scopes.include? "proposals" + end + + def self.from_doorkeeper_token(doorkeeper_token) + return Decidim::RestFull::Ability.new(nil) unless doorkeeper_token && doorkeeper_token.valid? + return Decidim::RestFull::Ability.new(nil) unless doorkeeper_token.application.is_a? Decidim::RestFull::ApiClient + + Decidim::RestFull::Ability.new(doorkeeper_token.application) + end + + private + + def perms_for_public; end + + def perms_for_system + can :read, ::Decidim::Organization if permissions.include? "system.organizations.read" + can :read, ::Decidim::User if permissions.include? "system.users.read" + end + + def perms_for_proposals; end + end + end +end diff --git a/app/models/decidim/rest_full/api_client.rb b/app/models/decidim/rest_full/api_client.rb index 2e36d47..325dc04 100644 --- a/app/models/decidim/rest_full/api_client.rb +++ b/app/models/decidim/rest_full/api_client.rb @@ -7,10 +7,15 @@ class ApiClient < ::Doorkeeper::Application include Decidim::Loggable belongs_to :organization, foreign_key: "decidim_organization_id", class_name: "Decidim::Organization", inverse_of: :api_clients + has_many :permissions, class_name: "Decidim::RestFull::Permission", dependent: :destroy validates :scopes, presence: true before_validation :dummy_attributes + def permission_strings + @permission_strings ||= permissions.pluck(:permission) + end + def owner organization end diff --git a/app/models/decidim/rest_full/permission.rb b/app/models/decidim/rest_full/permission.rb new file mode 100644 index 0000000..24a029d --- /dev/null +++ b/app/models/decidim/rest_full/permission.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Decidim + module RestFull + class Permission < ::ApplicationRecord + self.table_name = "decidim_rest_full_api_client_permissions" + + belongs_to :api_client, class_name: "Decidim::RestFull::ApiClient" + + validates :permission, presence: true + end + end +end diff --git a/app/views/decidim/rest_full/system/api_clients/edit.html.erb b/app/views/decidim/rest_full/system/api_clients/edit.html.erb index c061a10..c92abcc 100644 --- a/app/views/decidim/rest_full/system/api_clients/edit.html.erb +++ b/app/views/decidim/rest_full/system/api_clients/edit.html.erb @@ -22,59 +22,92 @@ <%= t(".permissions_scopes") %><%= @api_client.scopes.to_a %> +<%= form_for(@perm_form, method: :post, url: system_api_permissions_path ) do |form| %> +<%= form.hidden_field :api_client_id, value: @api_client.id %> + <% if @api_client.scopes.to_a.include?("system") %>

<%= t("scope_system", scope: "decidim.rest_full.models.api_client.fields") %>

-
+
<%= t('.auth_type') %>
-
+
<%= t('.organization_perm') %>
+ + +
+
+
+
<%= t('.user_perm') %>
+
+ + +
-
+
<%= t('.rails_perm') %>
-
+
+
+ <%= form.submit t(".update_permissions"), class: "button button__sm md:button__lg button__primary" %> +
+<% end %> <% end %> \ No newline at end of file diff --git a/config/locales/decidim_rest_full.en.yml b/config/locales/decidim_rest_full.en.yml index 8ab1392..c58123c 100644 --- a/config/locales/decidim_rest_full.en.yml +++ b/config/locales/decidim_rest_full.en.yml @@ -12,6 +12,22 @@ en: scope_meetings: "Meeting: manage meeting components" scope_debates: "Debate: manage debate components" scope_pages: "Page: manage page components" + permission: + oauth: + impersonate: "impersonate: Login as another user" + login: "login: login with username/password" + system: + organizations: + read: "read: List and detail of organizations" + update: "update: Update an organization" + destroy: "destroy: Remove definitly an organization" + users: + read: "read: List and detail of users" + update: "update: Update a user" + destroy: "destroy: Remove definitly a user" + server: + restart: "restart: Soft restart the server" + exec: "exec: Execute jobs" admin: menu: api_clients: "API Clients" @@ -38,7 +54,9 @@ en: client_secret: "Client Secret" auth_type: "Auth Type" organization_perm: "Organization" + user_perm: "User" rails_perm: "Ruby on Rails" + update_permissions: "Update permissions" show: go_back: "Back to the list" create: diff --git a/config/routes.rb b/config/routes.rb index 805ca51..86fca3a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,6 +6,7 @@ authenticate(:admin) do namespace "system" do resources :api_clients, controller: "/decidim/rest_full/system/api_clients" + resources :api_permissions, only: [:create], controller: "/decidim/rest_full/system/permissions" end end diff --git a/db/migrate/20241125061513_create_api_client_permissions.rb b/db/migrate/20241125061513_create_api_client_permissions.rb new file mode 100644 index 0000000..e123a30 --- /dev/null +++ b/db/migrate/20241125061513_create_api_client_permissions.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateApiClientPermissions < ActiveRecord::Migration[6.1] + def change + create_table :decidim_rest_full_api_client_permissions do |t| + t.references :api_client, null: false, foreign_key: { to_table: "oauth_applications" } + t.string :permission, null: false + t.timestamps + end + add_index :decidim_rest_full_api_client_permissions, [:api_client_id, :permission], unique: true, name: "index_decidim_restfull_permissions" + end +end diff --git a/decidim-rest_full.gemspec b/decidim-rest_full.gemspec index e0b0a17..9787d6c 100644 --- a/decidim-rest_full.gemspec +++ b/decidim-rest_full.gemspec @@ -25,13 +25,13 @@ Gem::Specification.new do |s| s.require_paths = ["lib"] s.add_dependency "api-pagination", "~> 6.0" + s.add_dependency "cancancan" s.add_dependency "decidim-admin", Decidim::RestFull.decidim_version s.add_dependency "decidim-comments", Decidim::RestFull.decidim_version s.add_dependency "decidim-core", Decidim::RestFull.decidim_version s.add_dependency "doorkeeper" s.add_dependency "jsonapi-serializer" s.add_dependency "rswag-api" - s.add_dependency "rswag-ui" s.metadata["rubygems_mfa_required"] = "true" end diff --git a/lib/decidim/rest_full.rb b/lib/decidim/rest_full.rb index 0f57c1b..07347f7 100644 --- a/lib/decidim/rest_full.rb +++ b/lib/decidim/rest_full.rb @@ -2,7 +2,7 @@ require "rails" require "active_support/all" - +require "cancan" require "rswag/api" require "jsonapi/serializer" require "api-pagination" diff --git a/lib/decidim/rest_full/api_exception.rb b/lib/decidim/rest_full/api_exception.rb index 8bd9922..bb5d3dd 100644 --- a/lib/decidim/rest_full/api_exception.rb +++ b/lib/decidim/rest_full/api_exception.rb @@ -20,7 +20,7 @@ module ApiException # Parsing Errors "ActionDispatch::Http::Parameters::ParseError" => { status: 400, message: "Malformed JSON request" }, - + "CanCan::AccessDenied" => { status: 401, message: "Unauthorized access" }, # Generic Application-Level Errors "BadRequest" => { status: 400, message: "Bad request" }, "Unauthorized" => { status: 401, message: "Unauthorized access" }, diff --git a/lib/decidim/rest_full/engine.rb b/lib/decidim/rest_full/engine.rb index d149032..7257193 100644 --- a/lib/decidim/rest_full/engine.rb +++ b/lib/decidim/rest_full/engine.rb @@ -32,13 +32,14 @@ class Engine < ::Rails::Engine api_client = Decidim::RestFull::ApiClient.find_by( uid: client_id, - organization: current_organization ) raise ::Doorkeeper::Errors::DoorkeeperError, "Invalid Api Client, check credentials" unless api_client + ability = Decidim::RestFull::Ability.new(api_client) case auth_type when "impersonate" + ability.authorize! :impersonate, Decidim::RestFull::ApiClient username = params.require("username") user = Decidim::User.find_by( nickname: username, @@ -110,6 +111,7 @@ class Engine < ::Rails::Engine end user when "login" + ability.authorize! :login, Decidim::RestFull::ApiClient user = Decidim::User.find_by( nickname: params.require("username"), organization: current_organization diff --git a/spec/decidim/rest_full/oauth/token_client_credential_spec.rb b/spec/decidim/rest_full/oauth/token_client_credential_spec.rb index edf9333..2946d74 100644 --- a/spec/decidim/rest_full/oauth/token_client_credential_spec.rb +++ b/spec/decidim/rest_full/oauth/token_client_credential_spec.rb @@ -6,6 +6,13 @@ let!(:organization) { create(:organization) } let!(:user) { create(:user, organization: organization, password: "decidim123456789!", password_confirmation: "decidim123456789!") } let!(:api_client) { create(:api_client, organization: organization) } + let!(:permissions) do + api_client.permissions = [ + api_client.permissions.build(permission: "oauth.impersonate"), + api_client.permissions.build(permission: "oauth.login") + ] + api_client.save! + end before do host! api_client.organization.host diff --git a/spec/decidim/rest_full/oauth/token_ropc_spec.rb b/spec/decidim/rest_full/oauth/token_ropc_spec.rb index 9d09949..8a67cb2 100644 --- a/spec/decidim/rest_full/oauth/token_ropc_spec.rb +++ b/spec/decidim/rest_full/oauth/token_ropc_spec.rb @@ -14,7 +14,15 @@ def uniq_nickname RSpec.describe "Decidim::Api::RestFull::System::ApplicationController", type: :request do let!(:organization) { create(:organization) } let!(:user) { create(:user, organization: organization, password: "decidim123456789!", password_confirmation: "decidim123456789!") } - let!(:api_client) { create(:api_client, organization: organization) } + let!(:api_client) do + api_client = create(:api_client, organization: organization) + api_client.permissions = [ + api_client.permissions.build(permission: "oauth.impersonate"), + api_client.permissions.build(permission: "oauth.login") + ] + api_client.save! + api_client.reload + end before do host! organization.host @@ -177,7 +185,14 @@ def uniq_nickname end context "with auth_type=login" do - let(:proposal_api_client) { create(:api_client, organization: organization, scopes: "proposals public") } + let(:proposal_api_client) do + api_client = create(:api_client, organization: organization, scopes: "proposals public") + api_client.permissions = [ + api_client.permissions.build(permission: "oauth.login") + ] + api_client.save! + api_client + end let(:body) do { diff --git a/spec/decidim/rest_full/public/spaces_index_spec.rb b/spec/decidim/rest_full/public/spaces_index_spec.rb index ee5ac76..59202ed 100644 --- a/spec/decidim/rest_full/public/spaces_index_spec.rb +++ b/spec/decidim/rest_full/public/spaces_index_spec.rb @@ -23,6 +23,7 @@ let!(:organization) { create(:organization) } let!(:api_client) { create(:api_client, organization: organization) } let!(:impersonation_token) { create(:oauth_access_token, scopes: "public", resource_owner_id: nil, application: api_client) } + let(:Authorization) { "Bearer #{impersonation_token.token}" } let!(:assembly) { create(:assembly, id: 6, organization: organization, title: { en: "My assembly for testing purpose", fr: "c'est une assemblée" }) } diff --git a/spec/decidim/rest_full/system/organization_index_spec.rb b/spec/decidim/rest_full/system/organization_index_spec.rb index 697f8f2..72418e2 100644 --- a/spec/decidim/rest_full/system/organization_index_spec.rb +++ b/spec/decidim/rest_full/system/organization_index_spec.rb @@ -7,20 +7,36 @@ tags "System" produces "application/json" security [{ credentialFlowBearer: ["system"] }] - parameter name: "populate[]", in: :query, style: :form, explode: true, schema: Api::Definitions::POPULATE_PARAM.call(Decidim::Api::RestFull::OrganizationSerializer), required: false + parameter name: "locales[]", in: :query, style: :form, explode: true, schema: Api::Definitions::LOCALES_PARAM, required: false parameter name: :page, in: :query, type: :integer, description: "Page number for pagination", required: false parameter name: :per_page, in: :query, type: :integer, description: "Number of items per page", required: false + let(:organization) { create(:organization) } + let(:api_client) do + api_client = create(:api_client, organization: organization, scopes: "system") + api_client.permissions = [ + api_client.permissions.build(permission: "oauth.impersonate"), + api_client.permissions.build(permission: "oauth.login"), + api_client.permissions.build(permission: "system.organizations.read") + ] + api_client.save! + api_client + end + let!(:user) { create(:user) } + let!(:impersonation_token) { create(:oauth_access_token, scopes: "system", resource_owner_id: user.id, application: api_client) } - let(:Authorization) { "Bearer #{create(:oauth_access_token, scopes: "system").token}" } + let(:Authorization) { "Bearer #{impersonation_token.token}" } + + before do + create(:rest_full_permission, api_client: api_client, permission: "system.organization.read") + end response "200", "Organizations listed" do consumes "application/json" produces "application/json" schema "$ref" => "#/components/schemas/organizations_response" - context "with populate[] and locale[] filter displayed fields and translated results" do - let(:"populate[]") { Decidim::Api::RestFull::OrganizationSerializer.db_fields } + context "with locale[] filter translated results" do let(:"locales[]") { %w(en fr) } let(:page) { 1 } let(:per_page) { 10 } @@ -49,14 +65,6 @@ consumes "application/json" produces "application/json" schema "$ref" => "#/components/schemas/api_error" - context "with invalid populate[] fields" do - let(:"populate[]") { ["invalid_field"] } - - run_test!(example_name: :bad_format) do |example| - message = JSON.parse(example.body)["detail"] - expect(message).to start_with("Not allowed populate param: invalid_field") - end - end context "with invalid locales[] fields" do let(:"locales[]") { ["invalid_locale"] } diff --git a/spec/decidim/rest_full/system/users_spec.rb b/spec/decidim/rest_full/system/users_spec.rb index eb03517..641c152 100644 --- a/spec/decidim/rest_full/system/users_spec.rb +++ b/spec/decidim/rest_full/system/users_spec.rb @@ -16,7 +16,16 @@ let!(:organization) { create(:organization) } let(:Authorization) { "Bearer #{impersonation_token.token}" } - let!(:api_client) { create(:api_client, organization: organization) } + let(:api_client) do + api_client = create(:api_client, organization: organization, scopes: "system") + api_client.permissions = [ + api_client.permissions.build(permission: "oauth.impersonate"), + api_client.permissions.build(permission: "oauth.login"), + api_client.permissions.build(permission: "system.users.read") + ] + api_client.save! + api_client + end let!(:impersonation_token) { create(:oauth_access_token, scopes: "system", resource_owner_id: nil, application: api_client) } before do diff --git a/spec/decidim/rest_full/test/factories/api_client.rb b/spec/decidim/rest_full/test/factories/api_client.rb index 00c4983..72f8ace 100644 --- a/spec/decidim/rest_full/test/factories/api_client.rb +++ b/spec/decidim/rest_full/test/factories/api_client.rb @@ -2,10 +2,16 @@ # spec/factories/oauth_applications.rb FactoryBot.define do + factory :rest_full_permission, class: "Decidim::RestFull::Permission" do + api_client { create(:api_client) } + permission { "dummy" } + end + factory :api_client, class: "Decidim::RestFull::ApiClient" do name { Faker::App.name } # Generate a random app name redirect_uri { Faker::Internet.url } # Generate a random URL scopes { "public" } - organization { create(:organization) } + association :organization, factory: :organization + permissions { [] } end end