Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release 6.8.0 - Add field and API endpoint for external groups (i.e. not from the HR system) #357

Closed
wants to merge 48 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
07d8a98
Add first `groups_external` test and implement first two steps
forevermatt Aug 13, 2024
42b3213
Implement (failing) test step for setting `groups_external` value
forevermatt Aug 13, 2024
6c6c14a
Use migration to add `user.groups_external` field to database
forevermatt Aug 13, 2024
41702f2
Regenerate the base models (excluding `groups_external` changes)
forevermatt Aug 13, 2024
6aa3d7a
Regenerate UserBase model to include `groups_external` field
forevermatt Aug 13, 2024
bd0b0b1
Set `user.groups_external`'s default value to be an empty string
forevermatt Aug 13, 2024
db30f68
Switch to fluent syntax (not DB-specific) for defining the new field
forevermatt Aug 13, 2024
3f47bb3
Regenerate UserBase model now that `groups_external` has a default value
forevermatt Aug 13, 2024
ffb2674
Get new test successfully signing in as the test user
forevermatt Aug 13, 2024
bb5f000
Add phpMyAdmin container for testdb
forevermatt Aug 14, 2024
7dbad6e
Improve groups_external test, implement next (failing) step
forevermatt Aug 14, 2024
11d9cf4
Merge branch 'develop' into feature/idp-1176-add-groups-external-field
forevermatt Aug 14, 2024
54fd8ba
Make groups_external test failure message more informative
forevermatt Aug 14, 2024
9fcaa42
Include groups_external values in a User's `member` list during login
forevermatt Aug 14, 2024
0d43d78
Ensure empty `groups` and empty `groups_external` fields work properly
forevermatt Aug 14, 2024
224d9b9
Update the base models (using our current version of Yii)
forevermatt Aug 14, 2024
29506ef
Revise Makefile to update base models after every composer update
forevermatt Aug 14, 2024
cfef138
Merge pull request #351 from silinternational/feature/update-base-models
forevermatt Aug 19, 2024
6543ed4
Merge branch 'develop' into feature/idp-1176-add-groups-external-field
forevermatt Aug 19, 2024
690289e
Customize the label for the new `groups_external` field
forevermatt Aug 19, 2024
4f6e211
Stop running CI/CD tests as soon as one of them fails
forevermatt Aug 19, 2024
ca0c7a2
Fix groups_external test to detect an empty string in the `members` list
forevermatt Aug 19, 2024
931f4db
Include the array contents in the failed-test output
forevermatt Aug 19, 2024
b608e9f
Remove now-unused new test step
forevermatt Aug 19, 2024
6ce06ac
Avoid adding empty string to `members` when `groups_external` is empty
forevermatt Aug 19, 2024
f1009a7
Add a place/reminder to paste the YouTrack issue's ID and title in PRs
forevermatt Aug 19, 2024
9727263
Separate the PR template "Changed" section for breaking vs. non-breaking
forevermatt Aug 19, 2024
38366ae
Simplify the wording of the checklist to not limit us to unit tests
forevermatt Aug 19, 2024
d677e2e
Extract `User->getMemberList()` method
forevermatt Aug 19, 2024
81131fa
Merge pull request #353 from silinternational/feature/refine-pull-req…
forevermatt Aug 19, 2024
747c877
Merge pull request #352 from silinternational/feature/idp-1176-add-gr…
forevermatt Aug 20, 2024
9ad7b66
Merge pull request #354 from silinternational/feature/idp-1176-add-gr…
forevermatt Aug 20, 2024
2f02ddc
Add a (failing) test for updating a user's external groups for an app
forevermatt Aug 20, 2024
d3c0cec
Add new endpoint to the RAML file describing ID Broker's API
forevermatt Aug 20, 2024
21615b9
Add another possible test to the list
forevermatt Aug 20, 2024
f6cd14b
Add controller action for updating a User's external groups
forevermatt Aug 20, 2024
1e32a84
Fix expected return status code to match api.raml
forevermatt Aug 20, 2024
257004a
Fix bug in test step (to check `groups_external`, not `groups`)
forevermatt Aug 20, 2024
838d822
Add methods for updating a user's list of external groups
forevermatt Aug 20, 2024
449bae7
Implement the test for removing an external group
forevermatt Aug 20, 2024
efe4e7c
Switch external-group tests to use more realistic values
forevermatt Aug 20, 2024
0b76181
Implement a test about leaving other apps' external groups unchanged
forevermatt Aug 20, 2024
8e3b043
Fix bug related to updating a User's external groups
forevermatt Aug 20, 2024
a55b671
Fix indentation in some tests
forevermatt Aug 20, 2024
31ec3af
Ensure the given external groups start with the given prefix
forevermatt Aug 20, 2024
de76f3a
Clarify that the app prefix shouldn't have a trailing hyphen
forevermatt Aug 20, 2024
85032eb
Fix typo in example in RAML file
forevermatt Aug 20, 2024
e6557b7
Merge pull request #355 from silinternational/feature/IDP-1182-update…
forevermatt Aug 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
(Backlog issue's ID and title)

---

### Added
-

### Changed
### Changed (non-breaking)
-

### Changed (BREAKING)
-

### Deprecated
Expand All @@ -20,6 +27,6 @@

### Feature PR Checklist
- [ ] Documentation (README, local.env.dist, etc.)
- [ ] Unit tests created or updated
- [ ] Tests created or updated
- [ ] Run `make composershow`
- [ ] Run `make psr2`
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ composershow:
composerupdate:
docker compose run --rm cli composer update
make composershow
make basemodels

db:
docker compose up -d db
Expand Down
37 changes: 37 additions & 0 deletions api.raml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ types:
"code": 0,
"status": 400
}
GroupsExternalUpdate:
type: object
properties:
email_address: string
app_prefix:
type: string
description: >
The app-specific prefix, without the trailing hyphen.
groups:
type: string[]
description: >
The desired list of groups, including the app-prefix.
example: |
{
"email_address": "john_smith@example.com",
"app_prefix": "myapp",
"groups": ["myapp-users", "myapp-managers"]
}
UserResponse:
description: >
Information on user record. Password is not included if a password
Expand Down Expand Up @@ -418,6 +436,25 @@ types:
description: A server-side error occurred.
body:
type: Error
/external-groups:
put:
description: >
Update a user's list of external groups for a given app-prefix to be
exactly the given list, leaving external groups with other prefixes
unchanged.
body:
type: GroupsExternalUpdate
responses:
204:
description: >
The user's external groups for that app-prefix were updated.
404:
description: >
No such user found.
422:
description: The given data does not satisfy some validation rule.
body:
type: Error
/{employee_id}:
get:
description: Get information about a specific user.
Expand Down
8 changes: 8 additions & 0 deletions application/behat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ default:
paths:
- "features/email.feature"
contexts: [ Sil\SilIdBroker\Behat\Context\EmailContext ]
groups_external_features:
paths:
- "features/groups-external.feature"
contexts: [ Sil\SilIdBroker\Behat\Context\GroupsExternalContext ]
groups_external_updates_features:
paths:
- "features/groups-external-updates.feature"
contexts: [ Sil\SilIdBroker\Behat\Context\GroupsExternalUpdatesContext ]
hibp_unit_tests_features:
paths:
- "features/hibp-unit-tests.feature"
Expand Down
4 changes: 2 additions & 2 deletions application/common/models/EmailLogBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public function rules()
[['user_id'], 'integer'],
[['message_type'], 'string'],
[['sent_utc'], 'safe'],
[['user_id'], 'exist', 'skipOnError' => true, 'targetClass' => User::className(), 'targetAttribute' => ['user_id' => 'id']],
[['user_id'], 'exist', 'skipOnError' => true, 'targetClass' => User::class, 'targetAttribute' => ['user_id' => 'id']],
];
}

Expand All @@ -58,6 +58,6 @@ public function attributeLabels()
*/
public function getUser()
{
return $this->hasOne(User::className(), ['id' => 'user_id']);
return $this->hasOne(User::class, ['id' => 'user_id']);
}
}
4 changes: 2 additions & 2 deletions application/common/models/InviteBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public function rules()
[['user_id'], 'integer'],
[['created_utc', 'expires_on'], 'safe'],
[['uuid'], 'string', 'max' => 36],
[['user_id'], 'exist', 'skipOnError' => true, 'targetClass' => User::className(), 'targetAttribute' => ['user_id' => 'id']],
[['user_id'], 'exist', 'skipOnError' => true, 'targetClass' => User::class, 'targetAttribute' => ['user_id' => 'id']],
];
}

Expand All @@ -60,6 +60,6 @@ public function attributeLabels()
*/
public function getUser()
{
return $this->hasOne(User::className(), ['id' => 'user_id']);
return $this->hasOne(User::class, ['id' => 'user_id']);
}
}
4 changes: 2 additions & 2 deletions application/common/models/MethodBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public function rules()
[['uid'], 'unique'],
[['user_id', 'value'], 'unique', 'targetAttribute' => ['user_id', 'value']],
[['verification_code'], 'unique'],
[['user_id'], 'exist', 'skipOnError' => true, 'targetClass' => User::className(), 'targetAttribute' => ['user_id' => 'id']],
[['user_id'], 'exist', 'skipOnError' => true, 'targetClass' => User::class, 'targetAttribute' => ['user_id' => 'id']],
];
}

Expand Down Expand Up @@ -73,6 +73,6 @@ public function attributeLabels()
*/
public function getUser()
{
return $this->hasOne(User::className(), ['id' => 'user_id']);
return $this->hasOne(User::class, ['id' => 'user_id']);
}
}
4 changes: 2 additions & 2 deletions application/common/models/MfaBackupcodeBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public function rules()
[['mfa_id'], 'integer'],
[['created_utc', 'expires_utc'], 'safe'],
[['value'], 'string', 'max' => 255],
[['mfa_id'], 'exist', 'skipOnError' => true, 'targetClass' => Mfa::className(), 'targetAttribute' => ['mfa_id' => 'id']],
[['mfa_id'], 'exist', 'skipOnError' => true, 'targetClass' => Mfa::class, 'targetAttribute' => ['mfa_id' => 'id']],
];
}

Expand All @@ -60,6 +60,6 @@ public function attributeLabels()
*/
public function getMfa()
{
return $this->hasOne(Mfa::className(), ['id' => 'mfa_id']);
return $this->hasOne(Mfa::class, ['id' => 'mfa_id']);
}
}
10 changes: 5 additions & 5 deletions application/common/models/MfaBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public function rules()
[['created_utc', 'last_used_utc'], 'safe'],
[['external_uuid', 'label'], 'string', 'max' => 64],
[['key_handle_hash'], 'string', 'max' => 255],
[['user_id'], 'exist', 'skipOnError' => true, 'targetClass' => User::className(), 'targetAttribute' => ['user_id' => 'id']],
[['user_id'], 'exist', 'skipOnError' => true, 'targetClass' => User::class, 'targetAttribute' => ['user_id' => 'id']],
];
}

Expand Down Expand Up @@ -73,7 +73,7 @@ public function attributeLabels()
*/
public function getMfaBackupcodes()
{
return $this->hasMany(MfaBackupcode::className(), ['mfa_id' => 'id']);
return $this->hasMany(MfaBackupcode::class, ['mfa_id' => 'id']);
}

/**
Expand All @@ -83,7 +83,7 @@ public function getMfaBackupcodes()
*/
public function getMfaFailedAttempts()
{
return $this->hasMany(MfaFailedAttempt::className(), ['mfa_id' => 'id']);
return $this->hasMany(MfaFailedAttempt::class, ['mfa_id' => 'id']);
}

/**
Expand All @@ -93,7 +93,7 @@ public function getMfaFailedAttempts()
*/
public function getMfaWebauthns()
{
return $this->hasMany(MfaWebauthn::className(), ['mfa_id' => 'id']);
return $this->hasMany(MfaWebauthn::class, ['mfa_id' => 'id']);
}

/**
Expand All @@ -103,6 +103,6 @@ public function getMfaWebauthns()
*/
public function getUser()
{
return $this->hasOne(User::className(), ['id' => 'user_id']);
return $this->hasOne(User::class, ['id' => 'user_id']);
}
}
4 changes: 2 additions & 2 deletions application/common/models/MfaFailedAttemptBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public function rules()
[['mfa_id', 'at_utc'], 'required'],
[['mfa_id'], 'integer'],
[['at_utc'], 'safe'],
[['mfa_id'], 'exist', 'skipOnError' => true, 'targetClass' => Mfa::className(), 'targetAttribute' => ['mfa_id' => 'id']],
[['mfa_id'], 'exist', 'skipOnError' => true, 'targetClass' => Mfa::class, 'targetAttribute' => ['mfa_id' => 'id']],
];
}

Expand All @@ -55,6 +55,6 @@ public function attributeLabels()
*/
public function getMfa()
{
return $this->hasOne(Mfa::className(), ['id' => 'mfa_id']);
return $this->hasOne(Mfa::class, ['id' => 'mfa_id']);
}
}
4 changes: 2 additions & 2 deletions application/common/models/MfaWebauthnBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public function rules()
[['created_utc', 'last_used_utc'], 'safe'],
[['key_handle_hash'], 'string', 'max' => 255],
[['label'], 'string', 'max' => 64],
[['mfa_id'], 'exist', 'skipOnError' => true, 'targetClass' => Mfa::className(), 'targetAttribute' => ['mfa_id' => 'id']],
[['mfa_id'], 'exist', 'skipOnError' => true, 'targetClass' => Mfa::class, 'targetAttribute' => ['mfa_id' => 'id']],
];
}

Expand All @@ -63,6 +63,6 @@ public function attributeLabels()
*/
public function getMfa()
{
return $this->hasOne(Mfa::className(), ['id' => 'mfa_id']);
return $this->hasOne(Mfa::class, ['id' => 'mfa_id']);
}
}
2 changes: 1 addition & 1 deletion application/common/models/PasswordBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,6 @@ public function attributeLabels()
*/
public function getUsers()
{
return $this->hasMany(User::className(), ['current_password_id' => 'id']);
return $this->hasMany(User::class, ['current_password_id' => 'id']);
}
}
101 changes: 96 additions & 5 deletions application/common/models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -569,11 +569,7 @@ public function fields(): array
},
'hide',
'member' => function (self $model) {
if (!empty($model->groups)) {
$member = explode(',', $model->groups);
}
$member[] = \Yii::$app->params['idpName'];
return $member;
return $model->getMemberList();
},
'mfa',
'method' => function (self $model) {
Expand Down Expand Up @@ -605,6 +601,26 @@ public function getDisplayName()
return $this->display_name ?? "$this->first_name $this->last_name";
}

/** @return string[] */
public function getMemberList(): array
{
if (!empty($this->groups)) {
$member = explode(',', $this->groups);
} else {
$member = [];
}

$externalGroups = explode(',', $this->groups_external);
foreach ($externalGroups as $externalGroup) {
if (!empty($externalGroup)) {
$member[] = $externalGroup;
}
}

$member[] = \Yii::$app->params['idpName'];
return $member;
}

/**
* Based on current time and presence of MFA and Method options,
* determine which "nag" to present to the user.
Expand Down Expand Up @@ -969,6 +985,7 @@ public function attributeLabels()
$labels['last_synced_utc'] = Yii::t('app', 'Last Synced (UTC)');
$labels['created_utc'] = Yii::t('app', 'Created (UTC)');
$labels['deactivated_utc'] = Yii::t('app', 'Deactivated (UTC)');
$labels['groups_external'] = Yii::t('app', 'Groups (External)');

return $labels;
}
Expand All @@ -986,6 +1003,80 @@ public function isPromptForMfa(): bool
return false;
}

public function updateExternalGroups($appPrefix, $appExternalGroups): bool
{
foreach ($appExternalGroups as $appExternalGroup) {
if (! str_starts_with($appExternalGroup, $appPrefix . '-')) {
$this->addErrors([
'groups_external' => sprintf(
'The given group %s does not start with the given prefix (%s)',
json_encode($appExternalGroup),
json_encode($appPrefix)
),
]);
return false;
}
}
$this->removeInMemoryExternalGroupsFor($appPrefix);
$this->addInMemoryExternalGroups($appExternalGroups);

$this->scenario = self::SCENARIO_UPDATE_USER;
$saved = $this->save(true, ['groups_external']);
if ($saved) {
return true;
} else {
Yii::warning(sprintf(
'Failed to update external groups for %s: %s',
$this->email,
join(', ', $this->getFirstErrors())
));
return false;
}
}

/**
* Remove all entries from this User object's list of external groups that
* begin with the given prefix.
*
* NOTE:
* This only updates the property in memory. It leaves the calling code to
* call `save()` on this User when desired.
*
* @param $appPrefix
* @return void
*/
private function removeInMemoryExternalGroupsFor($appPrefix)
{
$currentExternalGroups = explode(',', $this->groups_external);
$newExternalGroups = [];
foreach ($currentExternalGroups as $externalGroup) {
if (! str_starts_with($externalGroup, $appPrefix . '-')) {
$newExternalGroups[] = $externalGroup;
}
}
$this->groups_external = join(',', $newExternalGroups);
}

/**
* Add the given groups to this User objects' list of external groups.
*
* NOTE:
* This only updates the property in memory. It leaves the calling code to
* call `save()` on this User when desired.
*
* @param $newExternalGroups
* @return void
*/
private function addInMemoryExternalGroups($newExternalGroups)
{
$newCommaSeparatedExternalGroups = sprintf(
'%s,%s',
$this->groups_external,
join(',', $newExternalGroups)
);
$this->groups_external = trim($newCommaSeparatedExternalGroups, ',');
}

/**
* Update the date field that corresponds to the current nag state
*/
Expand Down
Loading