Commit 4733c36b authored by Serena Fang's avatar Serena Fang Committed by Pavel Shutsin

Allow group maintainers to list provisioned users for a group

Create EE::Auth::ProvisionedUsersFinder, which returns
a list of users who are provisioned by a SCIM identity
or managed by a SAML group. ProvisionedUsersFinder is then
exposed by a new API endpoint,
GET /groups/:provisioning_group_id/provisioned_users.
Group maintainers can use this endpoint to list
provisioned users for a given group.

Changelog: added
EE: true
parent f3273fcf
......@@ -32,7 +32,7 @@ class UsersFinder
end
def execute
users = User.all.order_id_desc
users = base_scope
users = by_username(users)
users = by_id(users)
users = by_admins(users)
......@@ -53,6 +53,10 @@ class UsersFinder
private
def base_scope
User.all.order_id_desc
end
def by_username(users)
return users unless params[:username]
......
# frozen_string_literal: true
class AddUserDetailsProvisioningIndex < Gitlab::Database::Migration[1.0]
disable_ddl_transaction!
INDEX_NAME = 'idx_user_details_on_provisioned_by_group_id_user_id'
OLD_INDEX_NAME = 'index_user_details_on_provisioned_by_group_id'
def up
add_concurrent_index :user_details, [:provisioned_by_group_id, :user_id], name: INDEX_NAME
remove_concurrent_index_by_name :user_details, OLD_INDEX_NAME
end
def down
add_concurrent_index :user_details, :provisioned_by_group_id, name: OLD_INDEX_NAME
remove_concurrent_index_by_name :user_details, INDEX_NAME
end
end
7a48d49d576d183198df370593642419da5707d8b018a23f541c448e2aa7ad65
\ No newline at end of file
......@@ -25345,6 +25345,8 @@ CREATE INDEX idx_security_scans_on_scan_type ON security_scans USING btree (scan
CREATE UNIQUE INDEX idx_serverless_domain_cluster_on_clusters_applications_knative ON serverless_domain_cluster USING btree (clusters_applications_knative_id);
CREATE INDEX idx_user_details_on_provisioned_by_group_id_user_id ON user_details USING btree (provisioned_by_group_id, user_id);
CREATE UNIQUE INDEX idx_vuln_signatures_on_occurrences_id_and_signature_sha ON vulnerability_finding_signatures USING btree (finding_id, signature_sha);
CREATE UNIQUE INDEX idx_vuln_signatures_uniqueness_signature_sha ON vulnerability_finding_signatures USING btree (finding_id, algorithm_type, signature_sha);
......@@ -27987,8 +27989,6 @@ CREATE UNIQUE INDEX index_user_details_on_phone ON user_details USING btree (pho
COMMENT ON INDEX index_user_details_on_phone IS 'JiHu-specific index';
CREATE INDEX index_user_details_on_provisioned_by_group_id ON user_details USING btree (provisioned_by_group_id);
CREATE UNIQUE INDEX index_user_details_on_user_id ON user_details USING btree (user_id);
CREATE INDEX index_user_group_callouts_on_group_id ON user_group_callouts USING btree (group_id);
......@@ -1523,3 +1523,75 @@ DELETE /groups/:id/push_rule
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](index.md#namespaced-path-encoding) |
## Provisioned users API **(PREMIUM)**
> Introduced in GitLab 14.8.
### List provisioned users
Get a list of users provisioned by a given group. Does not include users provisioned by subgroups.
Requires at least the Maintainer role on the group.
```plaintext
GET /group/:provisioned_group_id/provisioned_users
```
Parameters:
| Attribute | Type | Required | Description |
|------------------------|----------|----------|-----------------------------------------------|
| `provisioned_group_id` | integer | yes | The ID or [URL-encoded path of the group](index.md#namespaced-path-encoding) |
| `username` | string | no | Get a single user with a specific username |
| `search` | string | no | Filter list by name, email or username |
| `active` | boolean | no | Filters only active users |
| `blocked` | boolean | no | Filters only blocked users |
| `created_after` | datetime | no | Return users created after the specified time |
| `created_before` | datetime | no | Return users created before the specified time |
```json
[
{
id: 66,
username: "user22",
name: "John Doe22",
state: "active",
avatar_url: "https://www.gravatar.com/avatar/xxx?s=80&d=identicon",
web_url: "http://my.gitlab.com/user22",
created_at: "2021-09-10T12:48:22.381Z",
bio: "",
location: null,
public_email: "",
skype: "",
linkedin: "",
twitter: "",
website_url: "",
organization: null,
job_title: "",
pronouns: null,
bot: false,
work_information: null,
followers: 0,
following: 0,
local_time: null,
last_sign_in_at: null,
confirmed_at: "2021-09-10T12:48:22.330Z",
last_activity_on: null,
email: "user22@example.org",
theme_id: 1,
color_scheme_id: 1,
projects_limit: 100000,
current_sign_in_at: null,
identities: [ ],
can_create_group: true,
can_create_project: true,
two_factor_enabled: false,
external: false,
private_profile: false,
commit_email: "user22@example.org",
shared_runners_minutes_limit: null,
extra_shared_runners_minutes_limit: null
},
...
]
```
# frozen_string_literal: true
module Auth
class ProvisionedUsersFinder < UsersFinder
extend ::Gitlab::Utils::Override
private
override :base_scope
def base_scope
group_id = params[:provisioning_group_id]
raise "Provisioning group is required for ProvisionedUsersFinder" unless group_id
User.provisioned_by_group(group_id).order_id_desc
end
override :by_search
def by_search(users)
return users unless params[:search].present?
users.search(params[:search], with_private_emails: true)
end
end
end
......@@ -108,6 +108,10 @@ module EE
where(id: ::PersonalAccessToken.with_invalid_expires_at(expiration_date).select(:user_id))
end
scope :provisioned_by_group, -> (group_id) do
left_joins(:user_detail).where(user_detail: { provisioned_by_group_id: group_id })
end
accepts_nested_attributes_for :namespace
enum roadmap_layout: { weeks: 1, months: 4, quarters: 12 }
......
......@@ -157,6 +157,34 @@ module EE
render_api_error!(result[:message], 400)
end
end
desc 'Get a list of users provisioned by the group' do
success Entities::UserPublic
end
# rubocop: disable CodeReuse/ActiveRecord
get ':provisioning_group_id/provisioned_users' do
params do
requires :provisioning_group_id, type: String, desc: 'The ID of the group'
optional :username, type: String, desc: 'Get a single user with a specific username'
optional :search, type: String, desc: 'Search for a user name, email or username'
optional :active, type: Boolean, default: false, desc: 'Filters only active users'
optional :blocked, type: Boolean, default: false, desc: 'Filters only blocked users'
optional :created_after, type: DateTime, desc: 'Return users created after the specified time'
optional :created_before, type: DateTime, desc: 'Return users created before the specified time'
use :sort_params
use :pagination
end
group = find_group!(params[:provisioning_group_id])
authorize! :maintainer_access, group
users = ::Auth::ProvisionedUsersFinder.new(current_user, params).execute.preload(:identities)
present paginate(users), with: ::API::Entities::UserPublic
end
# rubocop: enable CodeReuse/ActiveRecord
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Auth::ProvisionedUsersFinder do
describe '#execute' do
let_it_be(:group) { create(:group) }
let_it_be(:regular_user) { create(:user) }
let_it_be(:saml_provider) { create(:saml_provider, group: group) }
let_it_be(:scim_identity) { create(:scim_identity, group: group) }
let_it_be(:developer) { create(:user).tap { |u| group.add_developer(u) } }
let_it_be(:maintainer) { create(:user).tap { |u| group.add_maintainer(u) } }
let_it_be(:provisioned_user) { create(:user, provisioned_by_group_id: group.id, created_at: 2.years.ago) }
let_it_be(:blocked_provisioned_user) { create(:user, :blocked, provisioned_by_group_id: group.id) }
let_it_be(:non_provisioned_user) { create(:user) { |u| group.add_maintainer(u) } }
let_it_be(:user) { create(:user) { |u| group.add_maintainer(u) } }
let(:params) { {} }
include_context 'UsersFinder#execute filter by project context'
subject(:finder) { described_class.new(user, params).execute }
describe '#base_scope' do
context 'when provisioning_group_id param is not passed' do
let(:params) { { provisioning_group_id: nil } }
it 'raises provisioning group error' do
expect { finder }.to raise_error RuntimeError, 'Provisioning group is required for ProvisionedUsersFinder'
end
end
context 'when provisioning_group_id param is passed' do
let(:params) { { provisioning_group_id: group.id } }
it 'returns provisioned_user' do
users = finder
expect(users).to eq([blocked_provisioned_user, provisioned_user])
end
end
end
describe '#by_search' do
let(:params) { { provisioning_group_id: group.id, search: provisioned_user.email } }
it 'filters by search' do
users = finder
expect(users).to eq([provisioned_user])
end
end
end
end
......@@ -104,6 +104,18 @@ RSpec.describe User do
expect(described_class.managed_by(group)).to match_array(managed_users)
end
end
describe '.provisioned_by_group' do
let_it_be(:group) { create(:group) }
let_it_be(:scim_identity) { create(:scim_identity, group: group) }
let_it_be(:provisioned_user) { create(:user, provisioned_by_group_id: scim_identity.group.id) }
let_it_be(:non_provisioned_user) { create(:user).tap { |u| group.add_developer(u) } }
let_it_be(:user) { create(:user) }
it 'returns users provisioned or managed by the specified group' do
expect(described_class.provisioned_by_group(group.id)).to match_array([provisioned_user])
end
end
end
describe 'after_create' do
......
......@@ -1056,6 +1056,115 @@ RSpec.describe API::Groups do
end
end
describe 'GET /groups/:provisioning_group_id/provisioned_users' do
let_it_be(:group) { create(:group) }
let_it_be(:regular_user) { create(:user) }
let_it_be(:saml_provider) { create(:saml_provider, group: group) }
let_it_be(:scim_identity) { create(:scim_identity, group: group) }
let_it_be(:developer) { create(:user).tap { |u| group.add_developer(u) } }
let_it_be(:maintainer) { create(:user).tap { |u| group.add_maintainer(u) } }
let_it_be(:provisioned_user) { create(:user, provisioned_by_group_id: group.id, created_at: 2.years.ago) }
let_it_be(:blocked_provisioned_user) { create(:user, :blocked, provisioned_by_group_id: group.id) }
let_it_be(:non_provisioned_user) { create(:user) { |u| group.add_maintainer(u) } }
let(:params) { { provisioning_group_id: group.id } }
subject(:get_provisioned_users) { get api("/groups/#{params[:provisioning_group_id]}/provisioned_users", current_user), params: params }
context 'when current_user is not a group maintainer' do
let_it_be(:current_user) { developer }
it 'returns 403' do
get_provisioned_users
expect(response).to have_gitlab_http_status(:forbidden)
end
end
context 'when current_user is a group maintainer' do
let_it_be(:current_user) { maintainer }
context 'requires group id' do
let(:params) { { provisioning_group_id: nil } }
it 'group not found' do
get_provisioned_users
expect(response).to have_gitlab_http_status(:not_found)
end
end
it 'returns a list of users provisioned by the group' do
get_provisioned_users
expect(json_response.pluck('id')).to eq([blocked_provisioned_user.id, provisioned_user.id])
end
context 'optional params' do
context 'search param' do
let(:params) { { provisioning_group_id: group.id, search: provisioned_user.email } }
it 'filters by search' do
get_provisioned_users
expect(json_response.pluck('id')).to eq([provisioned_user.id])
end
end
context 'username param' do
let(:params) { { provisioning_group_id: group.id, username: provisioned_user.username } }
it 'filters by username' do
get_provisioned_users
expect(json_response.pluck('id')).to eq([provisioned_user.id])
end
end
context 'blocked param' do
let(:params) { { provisioning_group_id: group.id, blocked: true } }
it 'filters by blocked' do
get_provisioned_users
expect(json_response.pluck('id')).to eq([blocked_provisioned_user.id])
end
end
context 'active param' do
let(:params) { { provisioning_group_id: group.id, active: true } }
it 'filters by active status' do
get_provisioned_users
expect(json_response.pluck('id')).to eq([provisioned_user.id])
end
end
context 'created_after' do
let(:params) { { provisioning_group_id: group.id, created_after: 1.year.ago } }
it 'filters by created_at' do
get_provisioned_users
expect(json_response.pluck('id')).to eq([blocked_provisioned_user.id])
end
end
context 'created_before' do
let(:params) { { provisioning_group_id: group.id, created_before: 1.year.ago } }
it 'filters by created_at' do
get_provisioned_users
expect(json_response.pluck('id')).to eq([provisioned_user.id])
end
end
end
end
end
def ldap_sync(group_id, user, sidekiq_testing_method)
Sidekiq::Testing.send(sidekiq_testing_method) do
post api("/groups/#{group_id}/ldap_sync", user)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment