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

PermissionsService 2.0 (just implementation) #5158

Merged
merged 9 commits into from
Dec 14, 2017

Conversation

scottbommarito
Copy link
Contributor

This is the implementation of the new version of the Permissions Service, without changes to the existing code.

You may have seen this code before in #5097

/// <summary>
/// Is <paramref name="currentPrincipal"/> allowed to perform an action with a requirement of <paramref name="permissionsRequirement"/> on <paramref name="packageRegistration"/>?
/// </summary>
public static bool IsRequirementSatisfied(PermissionsRequirement permissionsRequirement, IPrincipal currentPrincipal, PackageRegistration packageRegistration)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is mostly identical to the existent PermissionsService except:
1 - IsActionAllowed is now IsRequirementSatisfied
2 - Added a method for ReservedNamespaces
3 - Made some performance checks as suggested by @cristinamanum (specifically, we now check if having a specific kind of ownership would satisfy the requirement before checking if we have that ownership)

public static ActionRequiringPackagePermissions UploadNewPackageVersion =
new ActionRequiringPackagePermissions(
PermissionsRequirement.Owner | PermissionsRequirement.OrganizationAdmin | PermissionsRequirement.OrganizationCollaborator,
PermissionsRequirement.Owner | PermissionsRequirement.SiteAdmin);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PermissionsRequirement.SiteAdmin [](start = 47, length = 32)

This one is wrong. Admins shouldn't be able to upload new versions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

/// An action requiring permissions on a <see cref="ReservedNamespace"/> that can be done on behalf of another <see cref="User"/>.
/// </summary>
public class ActionRequiringReservedNamespacePermissions
: ActionRequiringEntityPermissions<IEnumerable<ReservedNamespace>>, IActionRequiringEntityPermissions<ActionOnNewPackageContext>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IEnumerable [](start = 43, length = 30)

Might be worth leaving a comment about why this is multiple namespaces, i.e. multiple namespaces could apply to a single ID.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


var possibleAccountsOnBehalfOf =
new[] { currentUser }
.Union(GetOwners(entity));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Union [](start = 21, length = 5)

Are set semantics necessary here? Are we sure comparison of user instances is working? Concat may be simpler.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to get rid of duplicates. I will replace this with Concat and Distinct

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you really care about duplicates here? Like most of the time it will be unique right? Distinct uses default comparer which is likely reference equals (or did we implement GetHashCode and Equals?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, the values calculated here will be piped directly to the UI for upload and API key scenarios.

var failure = IsAllowed(currentUser, accountOnBehalfOf, entity);
if (failure == PermissionsCheckResult.Allowed)
{
accountsAllowedOnBehalfOf = accountsAllowedOnBehalfOf.Concat(new[] { accountOnBehalfOf });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concat [](start = 74, length = 6)

Use List, don't need LINQ for everything

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense

/// </summary>
private static bool WouldSatisfy(PermissionsRequirement permissionsRequirementToCheck, PermissionsRequirement permissionsRequirementToSatisfy)
{
return (permissionsRequirementToCheck & permissionsRequirementToSatisfy) > 0;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[](start = 85, length = 1)

!= unless this is an unsigned flags or if you have tests asserting never negative flag values

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

== and != work with Flags?

I'm using the int logic here because the second parameter is a combination of multiple enum values, so I want to compare that they just have at least one value in common.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Flags] is just an attribute. The important details is that the enum is an int (unless you changed that...)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No what I mean is we'll be comparing 0b0010 and 0b1010 and expect it to return true. I didn't think enum or Flags made this comparison return true.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see now

if (entityOwners == null || !entityOwners.Any())
{
return false;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks admin permissions (on things they would otherwise do)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed--it was not only a logic issue but a tests issues

.OfType<Organization>()
.SelectMany(o => o.Members)
.Where(m => isUserMatch(m.Member))
.ToArray();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ToArray [](start = 17, length = 7)

ToList requires fewer allocations and is a better default for these one-off lists

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

return true;
}

return false;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

false [](start = 19, length = 5)

This is such a generic code path, I have a hard time thinking of cases that would fall through here. Might be worth commenting about that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i did some restructuring that should make this clearer to understand

Copy link
Member

@joelverhagen joelverhagen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⌚️

Very nice PR. Thanks for the break-down.

}

if (WouldSatisfy(PermissionsRequirement.SiteAdmin, permissionsRequirement) &&
isUserAdmin)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perf nit: always put the cheaper condition first so the 'and' operation will fail if it's false

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice catch

accountsAllowedOnBehalfOf = Enumerable.Empty<User>();

var possibleAccountsOnBehalfOf =
new[] { currentUser }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it ok to add the currentUser if the currentUser == null ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, it's necessary--if the current user is logged out but still able to perform the action, we still want to return true

}

return reservedNamespaces.Any(rn => PermissionsHelpers.IsRequirementSatisfied(ReservedNamespacePermissionsRequirement, account, rn)) ?
PermissionsCheckResult.Allowed : PermissionsCheckResult.ReservedNamespaceFailure;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it fine to have Any? Should not be permissions allowed for All the namespaces?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return true;
}

if (entityOwners == null || !entityOwners.Any())
Copy link
Contributor

@agr agr Dec 12, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a multiple enumeration of an IEnumerable (between here and lines 124 and 129). Since it is unlikely could be fixed, please use something else (ICollection at least).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

switched all methods in this class to use ICollection

/// <summary>
/// Determines whether <paramref name="currentUser"/> can perform this action on <paramref name="entity"/> on behalf of <paramref name="account"/>.
/// </summary>
public PermissionsCheckResult IsAllowed(User currentUser, User account, TEntity entity)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's not returning bool it shouldn't be called IsAllowed, same for overload below

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed to CheckPermissions

PackageRegistrationPermissionsRequirement = packageRegistrationPermissionsRequirement;
}

public PermissionsCheckResult IsAllowed(User currentUser, User account, Package package)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above: method name should be different.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed to CheckPermissions


protected override PermissionsCheckResult IsAllowedOnEntity(User account, IEnumerable<ReservedNamespace> reservedNamespaces)
{
if (!reservedNamespaces.Any())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple enumeration of IEnumerable

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed to IReadOnlyCollection

/// <summary>
/// No requirement--any user can satisfy the requirement.
/// </summary>
None = 1,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace values with 1 << 0, 1 << 1, 1 << 2, etc. maybe to emphasize bit masking?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


private static bool Includes(int i, ReturnsSatisfiedRequirementWhenExpected_State state)
{
return (i & (int)state) > 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be != instead of >, strictly speaking, but wouldn't be an issue unless we have more stuff in the ReturnsSatisfiedRequirementWhenExpected_State

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it

/// </summary>
public static bool IsRequirementSatisfied(PermissionsRequirement permissionsRequirement, IPrincipal currentPrincipal, IEnumerable<User> entityOwners)
{
if (WouldSatisfy(PermissionsRequirement.None, permissionsRequirement))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are 2 WouldSatisfy checks when prinipal is non-null. Recommend changing to:

if (currentPrincipal == null) {
    return WouldSatisfy(PermissionsRequirement.None, permissionsRequirement);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

if (currentUser == null)
{
return false;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see comment above about the WouldSatisfy check...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


foreach (var accountOnBehalfOf in possibleAccountsOnBehalfOf)
{
var failure = IsAllowed(currentUser, accountOnBehalfOf, entity);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't need to check IsAllowed on currentUser, right?

Also, can you just do the IsAllowed check on org owners when populating the original list? i.e., GetOwners(entity).Where(o => IsAllowed(currentUser, o, entity))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currentUser is required in all scenarios now--"can currentUser perform this action on entity on behalf of accountOnBehalfOf" is how this function call should be read

I tried to make it all LINQ, but since I have to split the initial population of the list into two pieces (see the currentUser != null check) I think this is cleaner.

/// An action requiring permissions on a <see cref="PackageRegistration"/> or <see cref="Package"/> that can be done on behalf of another <see cref="User"/>.
/// </summary>
public class ActionRequiringPackagePermissions
: ActionRequiringEntityPermissions<PackageRegistration>, IActionRequiringEntityPermissions<Package>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems unusual that the base class generic parameter is the package registration, but the interface parameter is the package version. Should/could both use package as the generic parameter?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

base class extends the interface for PackageRegistration and provides the actual implementation

by extending the interface for Package here we allow users to pass in Package instead of PackageRegistration for convenience

public static ActionRequiringPackagePermissions DisplayPrivatePackageMetadata =
new ActionRequiringPackagePermissions(
PermissionsRequirement.Owner | PermissionsRequirement.OrganizationAdmin | PermissionsRequirement.OrganizationCollaborator,
PermissionsRequirement.Owner | PermissionsRequirement.SiteAdmin);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: might be nice if these used named args, assuming shorter arg names:

new ActionRequiringPackagePermissions(
    onBehalfOfRequirement: PermissionsRequirement.Owner
         | PermissionsRequirement.OrganizationAdmin
         | PermissionsRequirement.OrganizationCollaborator,
    actionRequirement: PermissionsRequirement.Owner
        | PermissionsRequirement.SiteAdmin);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or, maybe improve readability by introducing private fields for OnBehalfOf combinations:

static PermissionsRequirement OnBehalfOfOwnerOrOrganizationMember;
static PermissionsRequirement OnBehalfOfOwnerOrOrganizationAdmin;

new ActionRequiringPackagePermissions(
    OnBehalfOfOwnerOrOrganizationMember,
    PermissionsRequirement.Owner | PermissionsRequirement.SiteAdmin);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like both of these--will do both

/// <summary>
/// The reason an action is not allowed is not known.
/// </summary>
UnknownFailure,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used outside tests?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it initially was, but is no longer. will remove

/// Describes the result of checking if an <see cref="ActionsRequiringPermissions"/> is allowed.
/// </summary>
public enum PermissionsCheckResult
{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this class, or is a bool (success/failure) sufficient?

Seems intended to provide 4 possible results:

  1. allowed
  2. exception
  3. on-behalf-of operation not allowed
  4. entity operation not allowed

I'm not sure we need to distinguish 3 & 4, and 2 seems like it should be normal exception handling.

Copy link
Contributor Author

@scottbommarito scottbommarito Dec 12, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this enum is necessary to provide the necessary messaging to the user.

"You don't have permission to upload new versions on behalf of 'y'." or "User 'y' does not have permissions to upload new versions of 'x'." are much more descriptive than "You don't have permissions to do something but we don't know what."

/// <summary>
/// No user can satisfy the requirement.
/// </summary>
Unsatisfiable = 0,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How will this be used? If a requirement can't be satisfied, then we don't support it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exists for syntactical cleanliness

public ActionOnNewPackageContext(string packageId, IReservedNamespaceService reservedNamespaceService)
{
PackageId = packageId;
ReservedNamespaceService = reservedNamespaceService;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null check

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch

@scottbommarito
Copy link
Contributor Author

@joelverhagen @chenriksson @skofman1 @agr

All feedback was addressed.

/// <summary>
/// Context object for checking permissions of an action involving a new package ID.
/// </summary>
public class ActionOnNewPackageContext
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one class per file. makes github search by file much easier

.SelectMany(o => o.Members)
.Where(m => isUserMatch(m.Member))
.ToList()
.AsReadOnly();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need AsReadOnly? Seems like an unnecessary allocation for a collection only used internally.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking it might be cleaner if we guaranteed this was read-only but I suppose it doesn't really matter.

{
private const PermissionsRequirement RequireOwnerOrSiteAdmin = PermissionsRequirement.Owner | PermissionsRequirement.SiteAdmin;
private const PermissionsRequirement RequireOwnerOrOrganizationAdmin = PermissionsRequirement.Owner | PermissionsRequirement.OrganizationAdmin;
private const PermissionsRequirement RequireOwnerOrOrganizationMember = PermissionsRequirement.Owner | PermissionsRequirement.OrganizationAdmin | PermissionsRequirement.OrganizationCollaborator;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: wrap lines?

public bool TryGetAccountsIsAllowedOnBehalfOf(User currentUser, TEntity entity, out IEnumerable<User> accountsAllowedOnBehalfOf)
{
var accountsAllowedOnBehalfOfList = new List<User>();
accountsAllowedOnBehalfOf = accountsAllowedOnBehalfOfList;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you need the local variable?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the out parameter is an IEnumerable<User> and not a List<User>, so I need to either store this local variable or cast the out parameter.

I'll switch to casting the out parameter instead of having another local variable, it looks cleaner.

foreach (var accountOnBehalfOf in possibleAccountsOnBehalfOf)
{
var failure = CheckPermissions(currentUser, accountOnBehalfOf, entity);
if (failure == PermissionsCheckResult.Allowed)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: failure sounds odd; maybe permissionsResult?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the enum used to be named PermissionsFailure and was renamed PermissionsCheckResult but this variable was never updated. It is now updated to result.

.Concat(currentUser.Organizations.Select(o => o.Organization));
}

possibleAccountsOnBehalfOf = possibleAccountsOnBehalfOf.Distinct(new UserEqualityComparer());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need Distinct here?

The membership table has PK of (OrganizationKey, MemberKey), so I don't think a User can have multiple memberships in the same Organization.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a distinct on not only the user's organizations, but also the owners of the entity we are checking permissions of (e.g. the owners of the package). Therefore, there can be duplicates.

{
PermissionsCheckResult CheckPermissions(User currentUser, User account, TEntity entity);
PermissionsCheckResult CheckPermissions(IPrincipal currentPrincipal, User account, TEntity entity);
bool TryGetAccountsIsAllowedOnBehalfOf(User currentUser, TEntity entity, out IEnumerable<User> accountsAllowedOnBehalfOf);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be TryGetAccountsAllowedOnBehalfOf?

@chenriksson
Copy link
Member

@scottbommarito My only remaining feedback is with the distinct call... please look at that, then I'm good.

@scottbommarito scottbommarito merged commit 377e115 into sb-feature-orgpermissions Dec 14, 2017
@scottbommarito scottbommarito deleted the sb-permserv2 branch May 1, 2018 19:50
@scottbommarito scottbommarito restored the sb-permserv2 branch July 22, 2019 23:18
@scottbommarito scottbommarito deleted the sb-permserv2 branch July 22, 2019 23:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants