Browse Source

Permission handling improved.

pull/332/head
Sebastian Stehle 7 years ago
parent
commit
ca67fa6d38
  1. 2
      src/Squidex.Domain.Apps.Core.Model/Permissions.cs
  2. 52
      src/Squidex.Infrastructure/Security/Permission.cs
  3. 2
      src/Squidex.Infrastructure/Security/PermissionSet.cs
  4. 2
      src/Squidex.Shared/Identity/SquidexClaimTypes.cs
  5. 2
      src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
  6. 9
      src/Squidex/Areas/Api/Controllers/Apps/Models/AppCreatedDto.cs
  7. 7
      src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs
  8. 34
      src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs
  9. 5
      src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs
  10. 5
      src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs
  11. 1
      src/Squidex/Config/Authentication/IdentityServerServices.cs
  12. 2
      src/Squidex/Config/Constants.cs
  13. 39
      src/Squidex/Pipeline/ApiPermissionAttribute.cs
  14. 4
      src/Squidex/Pipeline/AppResolverFilter.cs
  15. 6
      src/Squidex/app/features/administration/administration-area.component.html
  16. 4
      src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html
  17. 2
      src/Squidex/app/features/administration/pages/restore/restore-page.component.html
  18. 26
      src/Squidex/app/features/administration/pages/users/user-page.component.html
  19. 14
      src/Squidex/app/features/administration/pages/users/user-page.component.ts
  20. 6
      src/Squidex/app/features/administration/pages/users/users-page.component.html
  21. 13
      src/Squidex/app/features/administration/pages/users/users-page.component.ts
  22. 18
      src/Squidex/app/features/settings/settings-area.component.html
  23. 1
      src/Squidex/app/framework/internal.ts
  24. 106
      src/Squidex/app/framework/utils/permission.spec.ts
  25. 85
      src/Squidex/app/framework/utils/permission.ts
  26. 86
      src/Squidex/app/shared/components/permission.directive.ts
  27. 1
      src/Squidex/app/shared/declarations.ts
  28. 3
      src/Squidex/app/shared/module.ts
  29. 15
      src/Squidex/app/shared/services/apps.service.spec.ts
  30. 11
      src/Squidex/app/shared/services/apps.service.ts
  31. 28
      src/Squidex/app/shared/services/auth.service.ts
  32. 9
      src/Squidex/app/shared/state/apps.state.spec.ts
  33. 15
      src/Squidex/app/shell/pages/app/left-menu.component.html
  34. 2
      src/Squidex/app/shell/pages/internal/profile-menu.component.html
  35. 4
      src/Squidex/app/shell/pages/internal/profile-menu.component.ts
  36. 83
      tests/Squidex.Infrastructure.Tests/Security/PermissionTests.cs

2
src/Squidex.Domain.Apps.Core.Model/Permissions.cs

@ -14,7 +14,7 @@ namespace Squidex.Domain.Apps.Core
{
public const string All = "squidex.*";
public const string Admin = "squidex.admin*";
public const string Admin = "squidex.admin.*";
public const string AdminRestore = "squidex.admin.restore";
public const string AdminRestoreRead = "squidex.admin.restore.read";

52
src/Squidex.Infrastructure/Security/Permission.cs

@ -6,16 +6,19 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
namespace Squidex.Infrastructure.Security
{
public sealed class Permission : IComparable<Permission>, IEquatable<Permission>
{
private const string Any = "*";
private static readonly char[] Separators = { '.' };
private static readonly char[] MainSeparators = { '.' };
private static readonly char[] AlternativeSeparators = { '|' };
private readonly string description;
private readonly string id;
private readonly string[] idParts;
private readonly HashSet<string>[] idParts;
public string Id
{
@ -34,10 +37,22 @@ namespace Squidex.Infrastructure.Security
this.description = description;
this.id = id;
this.idParts = id.Split(Separators, StringSplitOptions.RemoveEmptyEntries);
idParts = id
.Split(MainSeparators, StringSplitOptions.RemoveEmptyEntries)
.Select(x =>
{
if (x == Any)
{
return null;
}
return new HashSet<string>(x.Split(AlternativeSeparators, StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase);
})
.ToArray();
}
public bool GivesPermissionTo(Permission permission)
public bool Allows(Permission permission)
{
if (permission == null)
{
@ -54,8 +69,33 @@ namespace Squidex.Infrastructure.Security
var lhs = idParts[i];
var rhs = permission.idParts[i];
if (!string.Equals(lhs, Any, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(lhs, rhs, StringComparison.OrdinalIgnoreCase))
if (lhs != null && (rhs == null || !lhs.Intersect(rhs).Any()))
{
return false;
}
}
return true;
}
public bool Includes(Permission permission)
{
if (permission == null)
{
return false;
}
if (idParts.Length < permission.idParts.Length)
{
return false;
}
for (var i = 0; i < permission.idParts.Length; i++)
{
var lhs = idParts[i];
var rhs = permission.idParts[i];
if (lhs != null && rhs != null && !lhs.Intersect(rhs).Any())
{
return false;
}

2
src/Squidex.Infrastructure/Security/PermissionSet.cs

@ -45,7 +45,7 @@ namespace Squidex.Infrastructure.Security
foreach (var permission in permissions)
{
if (permission.GivesPermissionTo(other))
if (permission.Allows(other))
{
return true;
}

2
src/Squidex.Shared/Identity/SquidexClaimTypes.cs

@ -19,7 +19,7 @@ namespace Squidex.Shared.Identity
public static readonly string SquidexHidden = "urn:squidex:hidden";
public static readonly string Permission = "urn:squidex:permission";
public static readonly string SquidexPermissions = "urn:squidex:permissions";
public static readonly string Prefix = "urn:squidex:";
}

2
src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs

@ -91,7 +91,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
var context = await CommandBus.PublishAsync(request.ToCommand());
var result = context.Result<EntityCreatedResult<Guid>>();
var response = AppCreatedDto.FromResult(result, appPlansProvider);
var response = AppCreatedDto.FromResult(request.Name, result, appPlansProvider);
return CreatedAtAction(nameof(GetApps), response);
}

9
src/Squidex/Areas/Api/Controllers/Apps/Models/AppCreatedDto.cs

@ -7,8 +7,8 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Entities.Apps.Services;
using Squidex.Infrastructure.Commands;
@ -26,8 +26,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// <summary>
/// The permission level of the user.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public AppContributorPermission Permission { get; set; }
public string[] Permissions { get; set; }
/// <summary>
/// The new version of the entity.
@ -44,12 +43,12 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// </summary>
public string PlanUpgrade { get; set; }
public static AppCreatedDto FromResult(EntityCreatedResult<Guid> result, IAppPlansProvider apps)
public static AppCreatedDto FromResult(string name, EntityCreatedResult<Guid> result, IAppPlansProvider apps)
{
var response = new AppCreatedDto
{
Id = result.IdOrValue.ToString(),
Permission = AppContributorPermission.Owner,
Permissions = AppContributorPermission.Owner.ToPermissions(name).Select(x => x.Id).ToArray(),
PlanName = apps.GetPlan(null)?.Name,
PlanUpgrade = apps.GetPlanUpgrade(null)?.Name,
Version = result.Version

7
src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs

@ -7,8 +7,8 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using NodaTime;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Entities.Apps;
@ -50,8 +50,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// <summary>
/// The permission level of the user.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public AppContributorPermission Permission { get; set; }
public string[] Permissions { get; set; }
/// <summary>
/// Gets the current plan name.
@ -67,7 +66,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
{
var response = SimpleMapper.Map(app, new AppDto());
response.Permission = app.Contributors[subject];
response.Permissions = app.Contributors[subject].ToPermissions(app.Name).Select(x => x.Id).ToArray();
response.PlanName = plans.GetPlanForApp(app)?.Name;
response.PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name;

34
src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs

@ -10,14 +10,12 @@ using System.Collections.Generic;
using System.Linq;
using NJsonSchema;
using NSwag;
using Squidex.Config;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.GenerateJsonSchema;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Pipeline.Swagger;
using Squidex.Shared.Identity;
namespace Squidex.Areas.Api.Controllers.Contents.Generator
{
@ -25,8 +23,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
{
private static readonly string SchemaQueryDescription;
private static readonly string SchemaBodyDescription;
private static readonly List<SwaggerSecurityRequirement> EditorSecurity;
private static readonly List<SwaggerSecurityRequirement> ReaderSecurity;
private readonly ContentSchemaBuilder schemaBuilder = new ContentSchemaBuilder();
private readonly SwaggerDocument document;
private readonly JsonSchema4 contentSchema;
@ -40,26 +36,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
{
SchemaBodyDescription = SwaggerHelper.LoadDocs("schemabody");
SchemaQueryDescription = SwaggerHelper.LoadDocs("schemaquery");
ReaderSecurity = new List<SwaggerSecurityRequirement>
{
new SwaggerSecurityRequirement
{
{
Constants.SecurityDefinition, new[] { SquidexRoles.AppReader }
}
}
};
EditorSecurity = new List<SwaggerSecurityRequirement>
{
new SwaggerSecurityRequirement
{
{
Constants.SecurityDefinition, new[] { SquidexRoles.AppEditor }
}
}
};
}
public SchemaSwaggerGenerator(SwaggerDocument document, string path, Schema schema, Func<string, JsonSchema4, JsonSchema4> schemaResolver, PartitionResolver partitionResolver)
@ -111,7 +87,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
{
operation.OperationId = $"Query{schemaType}Contents";
operation.Summary = $"Queries {schemaName} contents.";
operation.Security = ReaderSecurity;
operation.Description = SchemaQueryDescription;
@ -131,7 +106,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
{
operation.OperationId = $"Get{schemaType}Content";
operation.Summary = $"Get a {schemaName} content.";
operation.Security = ReaderSecurity;
operation.AddResponse("200", $"{schemaName} content found.", contentSchema);
});
@ -143,7 +117,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
{
operation.OperationId = $"Create{schemaType}Content";
operation.Summary = $"Create a {schemaName} content.";
operation.Security = EditorSecurity;
operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription);
operation.AddQueryParameter("publish", JsonObjectType.Boolean, "Set to true to autopublish content.");
@ -158,7 +131,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
{
operation.OperationId = $"Update{schemaType}Content";
operation.Summary = $"Update a {schemaName} content.";
operation.Security = EditorSecurity;
operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription);
@ -172,7 +144,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
{
operation.OperationId = $"Path{schemaType}Content";
operation.Summary = $"Patch a {schemaName} content.";
operation.Security = EditorSecurity;
operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription);
@ -186,7 +157,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
{
operation.OperationId = $"Publish{schemaType}Content";
operation.Summary = $"Publish a {schemaName} content.";
operation.Security = EditorSecurity;
operation.AddResponse("204", $"{schemaName} content published.");
});
@ -198,7 +168,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
{
operation.OperationId = $"Unpublish{schemaType}Content";
operation.Summary = $"Unpublish a {schemaName} content.";
operation.Security = EditorSecurity;
operation.AddResponse("204", $"{schemaName} content unpublished.");
});
@ -210,7 +179,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
{
operation.OperationId = $"Archive{schemaType}Content";
operation.Summary = $"Archive a {schemaName} content.";
operation.Security = EditorSecurity;
operation.AddResponse("204", $"{schemaName} content restored.");
});
@ -222,7 +190,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
{
operation.OperationId = $"Restore{schemaType}Content";
operation.Summary = $"Restore a {schemaName} content.";
operation.Security = EditorSecurity;
operation.AddResponse("204", $"{schemaName} content restored.");
});
@ -234,7 +201,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
{
operation.OperationId = $"Delete{schemaType}Content";
operation.Summary = $"Delete a {schemaName} content.";
operation.Security = EditorSecurity;
operation.AddResponse("204", $"{schemaName} content deleted.");
});

5
src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs

@ -98,6 +98,11 @@ namespace Squidex.Areas.IdentityServer.Config
{
JwtClaimTypes.Role
});
yield return new IdentityResource(Constants.PermissionsScope,
new[]
{
SquidexClaimTypes.SquidexPermissions
});
yield return new IdentityResource(Constants.ProfileScope,
new[]
{

5
src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs

@ -76,7 +76,8 @@ namespace Squidex.Areas.IdentityServer.Config
AllowedScopes = new List<string>
{
Constants.ApiScope,
Constants.RoleScope
Constants.RoleScope,
Constants.PermissionsScope
}
};
}
@ -115,6 +116,7 @@ namespace Squidex.Areas.IdentityServer.Config
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Email,
Constants.ApiScope,
Constants.PermissionsScope,
Constants.ProfileScope,
Constants.RoleScope
},
@ -141,6 +143,7 @@ namespace Squidex.Areas.IdentityServer.Config
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Email,
Constants.ApiScope,
Constants.PermissionsScope,
Constants.ProfileScope,
Constants.RoleScope
},

1
src/Squidex/Config/Authentication/IdentityServerServices.cs

@ -50,6 +50,7 @@ namespace Squidex.Config.Authentication
options.ClientSecret = Constants.InternalClientSecret;
options.RequireHttpsMetadata = identityOptions.RequiresHttps;
options.SaveTokens = true;
options.Scope.Add(Constants.PermissionsScope);
options.Scope.Add(Constants.ProfileScope);
options.Scope.Add(Constants.RoleScope);
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;

2
src/Squidex/Config/Constants.cs

@ -25,6 +25,8 @@ namespace Squidex.Config
public static readonly string RoleScope = "role";
public static readonly string PermissionsScope = "permissions";
public static readonly string ProfileScope = "squidex-profile";
public static readonly string FrontendClient = "squidex-frontend";

39
src/Squidex/Pipeline/ApiPermissionAttribute.cs

@ -12,40 +12,53 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared.Identity;
namespace Squidex.Pipeline
{
public sealed class ApiPermissionAttribute : AuthorizeAttribute, IAsyncActionFilter
{
private readonly string permissionId;
private readonly string[] permissionIds;
public ApiPermissionAttribute(string id = null)
public ApiPermissionAttribute(params string[] ids)
{
AuthenticationSchemes = IdentityServerAuthenticationDefaults.AuthenticationScheme;
permissionId = id;
permissionIds = ids;
}
public Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (permissionId != null)
if (permissionIds.Length > 0)
{
var id = permissionId;
var hasPermission = false;
foreach (var routeParam in context.RouteData.Values)
foreach (var permissionId in permissionIds)
{
id = id.Replace($"{{{routeParam.Key}}}", routeParam.Value?.ToString());
}
var id = permissionId;
foreach (var routeParam in context.RouteData.Values)
{
id = id.Replace($"{{{routeParam.Key}}}", routeParam.Value?.ToString());
}
var set = new PermissionSet(
context.HttpContext.User.FindAll(SquidexClaimTypes.Permission)
.Select(x => x.Value)
.Select(x => new Permission(x)));
var set = new PermissionSet(
context.HttpContext.User.FindAll(SquidexClaimTypes.SquidexPermissions)
.Select(x => x.Value)
.Select(x => new Permission(x)));
if (!set.GivesPermissionTo(new Permission(id)))
if (set.GivesPermissionTo(new Permission(id)))
{
hasPermission = true;
}
}
if (!hasPermission)
{
context.Result = new StatusCodeResult(403);
return TaskHelper.Done;
}
}

4
src/Squidex/Pipeline/AppResolverFilter.cs

@ -46,7 +46,7 @@ namespace Squidex.Pipeline
if (string.Equals(identity.FindFirst(identity.RoleClaimType)?.Value, SquidexRoles.Administrator))
{
identity.AddClaim(new Claim(SquidexClaimTypes.Permission, Permissions.Admin));
identity.AddClaim(new Claim(SquidexClaimTypes.SquidexPermissions, Permissions.Admin));
}
var appName = context.RouteData.Values["app"]?.ToString();
@ -73,7 +73,7 @@ namespace Squidex.Pipeline
foreach (var permission in permissions)
{
identity.AddClaim(new Claim(SquidexClaimTypes.Permission, permission.Id));
identity.AddClaim(new Claim(SquidexClaimTypes.SquidexPermissions, permission.Id));
}
context.HttpContext.Features.Set<IAppFeature>(new AppFeature(app));

6
src/Squidex/app/features/administration/administration-area.component.html

@ -2,17 +2,17 @@
<div class="sidebar">
<ul class="nav nav-panel flex-column">
<li class="nav-item">
<li class="nav-item" *sqxPermission="'squidex.admin.events.read'">
<a class="nav-link" routerLink="event-consumers" routerLinkActive="active">
<i class="nav-icon icon-time"></i> <div class="nav-text">Consumers</div>
</a>
</li>
<li class="nav-item">
<li class="nav-item" *sqxPermission="'squidex.admin.users.read'">
<a class="nav-link" routerLink="users" routerLinkActive="active">
<i class="nav-icon icon-user-o"></i> <div class="nav-text">Users</div>
</a>
</li>
<li class="nav-item">
<li class="nav-item" *sqxPermission="'squidex.admin.restore.read'">
<a class="nav-link" routerLink="restore" routerLinkActive="active">
<i class="nav-icon icon-backup"></i> <div class="nav-text">Restore</div>
</a>

4
src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html

@ -23,7 +23,7 @@
<th class="cell-auto-right">
Position
</th>
<th class="cell-actions-lg">
<th class="cell-actions-lg" *sqxPermission="'squidex.admin.events.manage'">
Actions
</th>
</tr>
@ -41,7 +41,7 @@
<td class="cell-auto-right">
<span>{{eventConsumer.position}}</span>
</td>
<td class="cell-actions-lg">
<td class="cell-actions-lg" *sqxPermission="'squidex.admin.events.manage'">
<button class="btn btn-link" (click)="reset(eventConsumer)" *ngIf="!eventConsumer.isResetting" title="Reset Event Consumer">
<i class="icon icon-reset"></i>
</button>

2
src/Squidex/app/features/administration/pages/restore/restore-page.component.html

@ -49,7 +49,7 @@
</div>
</ng-container>
<div class="table-items-row">
<div class="table-items-row" *sqxPermission="'squidex.admin.restore.create'">
<form [formGroup]="restoreForm.form" (submit)="restore()">
<div class="row no-gutters">
<div class="col">

26
src/Squidex/app/features/administration/pages/users/user-page.component.html

@ -5,21 +5,33 @@
<sqx-panel desiredWidth="26rem" isBlank="true">
<ng-container title>
<ng-container *ngIf="usersState.selectedUser | async; else noUser">
<ng-container *ngIf="usersState.selectedUser | async; else noUserTitle">
Edit User
</ng-container>
<ng-template #noUser>
<ng-template #noUserTitle>
New User
</ng-template>
</ng-container>
<ng-container menu>
<button type="submit" class="btn btn-primary" title="CTRL + S">
Save
</button>
<sqx-shortcut keys="ctrl+s" (trigger)="save()"></sqx-shortcut>
<ng-container *ngIf="usersState.selectedUser | async; else noUserMenu">
<ng-container *ngIf="canUpdate">
<button type="submit" class="btn btn-primary" title="CTRL + S">
Save
</button>
<sqx-shortcut keys="ctrl+s" (trigger)="save()"></sqx-shortcut>
</ng-container>
</ng-container>
<ng-template #noUserMenu>
<button type="submit" class="btn btn-primary" title="CTRL + S">
Save
</button>
<sqx-shortcut keys="ctrl+s" (trigger)="save()"></sqx-shortcut>
</ng-template>
</ng-container>
<ng-container content>

14
src/Squidex/app/features/administration/pages/users/user-page.component.ts

@ -10,9 +10,13 @@ import { FormBuilder } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { AuthService, Permission, permissionsAllow } from '@app/shared';
import { UserDto } from './../../services/users.service';
import { UserForm, UsersState } from './../../state/users.state';
const UserUpdatePermission = new Permission('squidex.admin.users.update');
@Component({
selector: 'sqx-user-page',
styleUrls: ['./user-page.component.scss'],
@ -21,16 +25,18 @@ import { UserForm, UsersState } from './../../state/users.state';
export class UserPageComponent implements OnDestroy, OnInit {
private selectedUserSubscription: Subscription;
public user?: { user: UserDto, isCurrentUser: boolean };
public canUpdate = false;
public user?: { user: UserDto, isCurrentUser: boolean };
public userForm = new UserForm(this.formBuilder);
constructor(
constructor(authService: AuthService,
public readonly usersState: UsersState,
private readonly formBuilder: FormBuilder,
private readonly route: ActivatedRoute,
private readonly router: Router
) {
this.canUpdate = permissionsAllow(authService.user!.permissions, UserUpdatePermission);
}
public ngOnDestroy() {
@ -46,6 +52,10 @@ export class UserPageComponent implements OnDestroy, OnInit {
if (selectedUser) {
this.userForm.load(selectedUser.user);
}
if (!this.canUpdate && selectedUser) {
this.userForm.form.disable();
}
});
}

6
src/Squidex/app/features/administration/pages/users/users-page.component.html

@ -18,7 +18,7 @@
<input class="form-control" #inputFind [formControl]="usersFilter" placeholder="Search for user" />
</form>
<button class="btn btn-success" #buttonNew routerLink="new" title="New User (CTRL + N)">
<button class="btn btn-success" #buttonNew routerLink="new" title="New User (CTRL + N)" *sqxPermission="'squidex.admin.users.create'">
<i class="icon-plus"></i> New
</button>
</ng-container>
@ -37,7 +37,7 @@
<th class="cell-auto">
Email
</th>
<th class="cell-actions">
<th class="cell-actions" *ngIf="canLock">
Actions
</th>
</tr>
@ -59,7 +59,7 @@
<td class="cell-auto">
<span class="user-email table-cell">{{userInfo.user.email}}</span>
</td>
<td class="cell-actions">
<td class="cell-actions" *ngIf="canLock">
<ng-container *ngIf="!userInfo.isCurrentUser">
<button class="btn btn-link" (click)="lock(userInfo.user); $event.stopPropagation();" *ngIf="!userInfo.user.isLocked" title="Lock User">
<i class="icon icon-unlocked"></i>

13
src/Squidex/app/features/administration/pages/users/users-page.component.ts

@ -9,9 +9,17 @@ import { Component, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import { onErrorResumeNext } from 'rxjs/operators';
import {
AuthService,
Permission,
permissionsAllow
} from '@app/shared';
import { UserDto } from './../../services/users.service';
import { UsersState } from './../../state/users.state';
const UserLockPermission = new Permission('squidex.admin.users.lock');
@Component({
selector: 'sqx-users-page',
styleUrls: ['./users-page.component.scss'],
@ -20,9 +28,12 @@ import { UsersState } from './../../state/users.state';
export class UsersPageComponent implements OnInit {
public usersFilter = new FormControl();
constructor(
public canLock: boolean;
constructor(authService: AuthService,
public readonly usersState: UsersState
) {
this.canLock = permissionsAllow(authService.user!.permissions, UserLockPermission);
}
public ngOnInit() {

18
src/Squidex/app/features/settings/settings-area.component.html

@ -6,44 +6,44 @@
</ng-container>
<ng-container content>
<ul class="nav nav-panel nav-dark flex-column" *ngIf="appsState.selectedApp | async; let selectedApp">
<li class="nav-item" *ngIf="selectedApp.permission === 'Owner'">
<ul class="nav nav-panel nav-dark flex-column">
<li class="nav-item" *sqxPermission="'squidex.apps.{app}.backups.read'">
<a class="nav-link" routerLink="backups" routerLinkActive="active">
Backups
<i class="icon-angle-right"></i>
</a>
</li>
<li class="nav-item">
<li class="nav-item" *sqxPermission="'squidex.apps.{app}.clients.read'">
<a class="nav-link" routerLink="clients" routerLinkActive="active">
Clients
<i class="icon-angle-right"></i>
</a>
</li>
<li class="nav-item" *ngIf="selectedApp.permission === 'Owner'">
<li class="nav-item" *sqxPermission="'squidex.apps.{app}.contributors.read'">
<a class="nav-link" routerLink="contributors" routerLinkActive="active">
Contributors
<i class="icon-angle-right"></i>
</a>
</li>
<li class="nav-item">
<a class="nav-link" routerLink="languages" routerLinkActive="active">
<li class="nav-item" *sqxPermission="'squidex.apps.{app}.languages.read'">
<a class="nav-link" routerLink="clients" routerLinkActive="active">
Languages
<i class="icon-angle-right"></i>
</a>
</li>
<li class="nav-item">
<li class="nav-item" *sqxPermission="'squidex.apps.{app}.patterns.read'">
<a class="nav-link" routerLink="patterns" routerLinkActive="active">
Patterns
<i class="icon-angle-right"></i>
</a>
</li>
<li class="nav-item" *ngIf="selectedApp.permission === 'Owner'">
<li class="nav-item" *sqxPermission="'squidex.apps.{app}.plans.read'">
<a class="nav-link" routerLink="plans" routerLinkActive="active">
Subscription
<i class="icon-angle-right"></i>
</a>
</li>
<li class="nav-item" *ngIf="selectedApp.permission === 'Owner'">
<li class="nav-item" *sqxPermission="'squidex.apps.{app}.delete'">
<a class="nav-link" routerLink="more" routerLinkActive="active">
More
<i class="icon-angle-right"></i>

1
src/Squidex/app/framework/internal.ts

@ -28,6 +28,7 @@ export * from './utils/lazy';
export * from './utils/math-helper';
export * from './utils/modal-view';
export * from './utils/pager';
export * from './utils/permission';
export * from './utils/rxjs-extensions';
export * from './utils/string-helper';
export * from './utils/types';

106
src/Squidex/app/framework/utils/permission.spec.ts

@ -0,0 +1,106 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Permission, permissionsAllow } from './permission';
describe('Permission', () => {
it('Should_return_true_if_given_and_requested_permission_have_wildcards', () => {
const g = new Permission('app.*');
const r = new Permission('app.*');
expect(g.allows(r)).toBeTruthy();
});
it('Should_return_true_if_given_permission_equals_requested_permission', () => {
const g = new Permission('app.contents');
const r = new Permission('app.contents');
expect(g.allows(r)).toBeTruthy();
});
it('Should_return_true_if_given_permission_is_parent_of_requested_permission', () => {
const g = new Permission('app');
const r = new Permission('app.contents');
expect(g.allows(r)).toBeTruthy();
});
it('Should_return_true_if_given_permission_is_alternative_of_requested_permission', () => {
const g = new Permission('app.contents|schemas');
const r = new Permission('app.contents');
expect(g.allows(r)).toBeTruthy();
});
it('Should_return_true_if_given_permission_equals_alternative_requested_permission', () => {
const g = new Permission('app.contents');
const r = new Permission('app.contents|schemas');
expect(g.allows(r)).toBeTruthy();
});
it('Should_return_true_if_given_permission_has_wildcard_for_requested_permission', () => {
const g = new Permission('app.*');
const r = new Permission('app.contents');
expect(g.allows(r)).toBeTruthy();
});
it('Should_return_false_if_given_permission_not_equals_requested_permission', () => {
const g = new Permission('app.contents');
const r = new Permission('app.assets');
expect(g.allows(r)).toBeFalsy();
});
it('Should_return_false_if_given_permission_is_child_of_requested_permission', () => {
const g = new Permission('app.contents');
const r = new Permission('app');
expect(g.allows(r)).toBeFalsy();
});
it('Should_return_false_if_given_permission_has_no_wildcard_but_requested_has', () => {
const g = new Permission('app.contents');
const r = new Permission('app.*');
expect(g.allows(r)).toBeFalsy();
});
it('Should_return_false_if_given_requested_permission_is_null', () => {
const g = new Permission('app.contents');
expect(g.allows(null!)).toBeFalsy();
});
it('Should_return_true_if_any_permission_gives_permission_to_request', () => {
const sut = [
new Permission('app.contents'),
new Permission('app.assets')
];
expect(permissionsAllow(sut, new Permission('app.contents'))).toBeTruthy();
});
it('Should_return_false_if_none_permission_gives_permission_to_request', () => {
const sut = [
new Permission('app.contents'),
new Permission('app.assets')
];
expect(permissionsAllow(sut, new Permission('app.schemas'))).toBeFalsy();
});
it('Should_return_false_if_permission_to_request_is_null', () => {
const sut = [
new Permission('app.contents'),
new Permission('app.assets')
];
expect(permissionsAllow(sut, null!)).toBeFalsy();
});
});

85
src/Squidex/app/framework/utils/permission.ts

@ -0,0 +1,85 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Types } from './types';
export class Permission {
private readonly parts: ({ [key: string]: true } | null)[];
constructor(
public readonly id: string
) {
this.parts = id.split('.').map(x => {
if (x === '*') {
return null;
} else {
const result: { [key: string]: true } = {};
for (let p of x.split('|')) {
result[p] = true;
}
return result;
}
});
}
public allows(permission?: Permission | string) {
if (!permission) {
return false;
}
if (Types.isString(permission)) {
permission = new Permission(permission);
}
if (this.parts.length > permission.parts.length) {
return false;
}
for (let i = 0; i < this.parts.length; i++) {
let lhs = this.parts[i];
let rhs = permission.parts[i];
if (lhs !== null && (rhs === null || !Permission.any(lhs, rhs))) {
return false;
}
}
return true;
}
private static any(lhs: { [key: string]: true }, rhs: { [key: string]: true }) {
for (let key in lhs) {
if (rhs[key]) {
return true;
}
}
for (let key in rhs) {
if (lhs[key]) {
return true;
}
}
return false;
}
}
export function permissionsAllow(permissions: Permission[], other: Permission) {
if (!other) {
return false;
}
for (let permission of permissions) {
if (permission.allows(other)) {
return true;
}
}
return false;
}

86
src/Squidex/app/shared/components/permission.directive.ts

@ -0,0 +1,86 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Directive, Input, OnChanges, TemplateRef, ViewContainerRef } from '@angular/core';
import {
AppDto,
AppsState,
AuthService,
Permission,
permissionsAllow,
SchemaDto,
SchemasState
} from '@app/shared/internal';
@Directive({
selector: '[sqxPermission]'
})
export class PermissionDirective implements OnChanges {
private viewCreated = false;
@Input('sqxPermissionApp')
public app?: AppDto;
@Input('sqxPermissionSchema')
public schema?: SchemaDto;
@Input('sqxPermission')
public permissions: string;
constructor(
private readonly authService: AuthService,
private readonly appsState: AppsState,
private readonly schemasState: SchemasState,
private readonly templateRef: TemplateRef<any>,
private readonly viewContainer: ViewContainerRef
) {
}
public ngOnChanges() {
let show = false;
if (this.permissions) {
for (let id of this.permissions.split(';')) {
const app = this.app || this.appsState.snapshot.selectedApp;
if (app) {
id = id.replace('{app}', app.name);
}
const schema = this.schema || this.schemasState.snapshot.selectedSchema;
if (schema) {
id = id.replace('{name}', schema.name);
}
const permission = new Permission(id);
if (app && permissionsAllow(app.permissions, permission)) {
show = true;
}
if (!show) {
show = permissionsAllow(this.authService.user!.permissions, permission);
}
if (show) {
break;
}
}
}
if (show && !this.viewCreated) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.viewCreated = true;
} else if (show && this.viewCreated) {
this.viewContainer.clear();
this.viewCreated = false;
}
}
}

1
src/Squidex/app/shared/declarations.ts

@ -17,6 +17,7 @@ export * from './components/history.component';
export * from './components/history-list.component';
export * from './components/language-selector.component';
export * from './components/markdown-editor.component';
export * from './components/permission.directive';
export * from './components/pipes';
export * from './components/rich-editor.component';
export * from './components/schema-category.component';

3
src/Squidex/app/shared/module.ts

@ -59,6 +59,7 @@ import {
MustBeAuthenticatedGuard,
MustBeNotAuthenticatedGuard,
PatternsState,
PermissionDirective,
PlansService,
PlansState,
RichEditorComponent,
@ -110,6 +111,7 @@ import {
HistoryMessagePipe,
LanguageSelectorComponent,
MarkdownEditorComponent,
PermissionDirective,
SchemaCategoryComponent,
UserDtoPicture,
UserIdPicturePipe,
@ -137,6 +139,7 @@ import {
HistoryMessagePipe,
LanguageSelectorComponent,
MarkdownEditorComponent,
PermissionDirective,
RouterModule,
SchemaCategoryComponent,
SearchFormComponent,

15
src/Squidex/app/shared/services/apps.service.spec.ts

@ -14,7 +14,8 @@ import {
AppDto,
AppsService,
CreateAppDto,
DateTime
DateTime,
Permission
} from './../';
describe('AppsService', () => {
@ -55,7 +56,7 @@ describe('AppsService', () => {
{
id: '123',
name: 'name1',
permission: 'Owner',
permissions: ['Owner'],
created: '2016-01-01',
lastModified: '2016-02-02',
planName: 'Free',
@ -64,7 +65,7 @@ describe('AppsService', () => {
{
id: '456',
name: 'name2',
permission: 'Owner',
permissions: ['Owner'],
created: '2017-01-01',
lastModified: '2017-02-02',
planName: 'Basic',
@ -74,8 +75,8 @@ describe('AppsService', () => {
expect(apps!).toEqual(
[
new AppDto('123', 'name1', 'Owner', DateTime.parseISO('2016-01-01'), DateTime.parseISO('2016-02-02'), 'Free', 'Basic'),
new AppDto('456', 'name2', 'Owner', DateTime.parseISO('2017-01-01'), DateTime.parseISO('2017-02-02'), 'Basic', 'Enterprise')
new AppDto('123', 'name1', [new Permission('Owner')], DateTime.parseISO('2016-01-01'), DateTime.parseISO('2016-02-02'), 'Free', 'Basic'),
new AppDto('456', 'name2', [new Permission('Owner')], DateTime.parseISO('2017-01-01'), DateTime.parseISO('2017-02-02'), 'Basic', 'Enterprise')
]);
}));
@ -97,12 +98,12 @@ describe('AppsService', () => {
req.flush({
id: '123',
permission: 'Reader',
permissions: ['Reader'],
planName: 'Basic',
planUpgrade: 'Enterprise'
});
expect(app!).toEqual(new AppDto('123', dto.name, 'Reader', now, now, 'Basic', 'Enterprise'));
expect(app!).toEqual(new AppDto('123', dto.name, [new Permission('Reader')], now, now, 'Basic', 'Enterprise'));
}));
it('should make delete request to archive app',

11
src/Squidex/app/shared/services/apps.service.ts

@ -16,6 +16,7 @@ import {
DateTime,
HTTP,
Model,
Permission,
pretifyError
} from '@app/framework';
@ -23,7 +24,7 @@ export class AppDto extends Model {
constructor(
public readonly id: string,
public readonly name: string,
public readonly permission: string,
public readonly permissions: Permission[],
public readonly created: DateTime,
public readonly lastModified: DateTime,
public readonly planName: string,
@ -60,10 +61,12 @@ export class AppsService {
const items: any[] = body;
return items.map(item => {
const permissions = (<string[]>item.permissions).map(x => new Permission(x));
return new AppDto(
item.id,
item.name,
item.permission,
permissions,
DateTime.parseISO(item.created),
DateTime.parseISO(item.lastModified),
item.planName,
@ -82,7 +85,9 @@ export class AppsService {
now = now || DateTime.now();
return new AppDto(body.id, dto.name, body.permission, now, now, body.planName, body.planUpgrade);
const permissions = (<string[]>body.permissions).map(x => new Permission(x));
return new AppDto(body.id, dto.name, permissions, now, now, body.planName, body.planUpgrade);
}),
tap(() => {
this.analytics.trackEvent('App', 'Created', dto.name);

28
src/Squidex/app/shared/services/auth.service.ts

@ -7,7 +7,7 @@
import { Injectable } from '@angular/core';
import { Observable, Observer, of, ReplaySubject, throwError, TimeoutError } from 'rxjs';
import { concat, delay, mergeMap, retryWhen, take } from 'rxjs/operators';
import { concat, delay, mergeMap, retryWhen, take, timeout } from 'rxjs/operators';
import {
Log,
@ -16,10 +16,15 @@ import {
WebStorageStateStore
} from 'oidc-client';
import { ApiUrlConfig, Types } from '@app/framework';
import { timeout } from 'rxjs/internal/operators/timeout';
import {
ApiUrlConfig,
Permission,
Types
} from '@app/framework';
export class Profile {
public readonly permissions: Permission[];
public get id(): string {
return this.user.profile['sub'];
}
@ -32,10 +37,6 @@ export class Profile {
return this.user.profile['urn:squidex:picture'];
}
public get isAdmin(): boolean {
return this.user.profile['role'] && this.user.profile['role'].toUpperCase() === 'ADMINISTRATOR';
}
public get isExpired(): boolean {
return this.user.expired || false;
}
@ -51,6 +52,17 @@ export class Profile {
constructor(
public readonly user: User
) {
const permissions = this.user.profile['uri:squidex:permissions'];
if (Types.isArrayOfString(permissions)) {
this.permissions = permissions.map(x => new Permission(x));
} else {
this.permissions = [];
}
if (this.user.profile['role'] === 'ADMINISTRATOR') {
this.permissions.push( new Permission('squidex.admin'));
}
}
}
@ -77,7 +89,7 @@ export class AuthService {
this.userManager = new UserManager({
client_id: 'squidex-frontend',
scope: 'squidex-api openid profile email squidex-profile role',
scope: 'squidex-api openid profile email squidex-profile role permissions',
response_type: 'id_token token',
redirect_uri: apiUrl.buildUrl('login;'),
post_logout_redirect_uri: apiUrl.buildUrl('logout'),

9
src/Squidex/app/shared/state/apps.state.spec.ts

@ -14,18 +14,19 @@ import {
AppsState,
CreateAppDto,
DateTime,
DialogService
DialogService,
Permission
} from './../';
describe('AppsState', () => {
const now = DateTime.now();
const oldApps = [
new AppDto('id1', 'old-name1', 'Owner', now, now, 'Free', 'Plan'),
new AppDto('id2', 'old-name2', 'Owner', now, now, 'Free', 'Plan')
new AppDto('id1', 'old-name1', [new Permission('Owner')], now, now, 'Free', 'Plan'),
new AppDto('id2', 'old-name2', [new Permission('Owner')], now, now, 'Free', 'Plan')
];
const newApp = new AppDto('id3', 'new-name', 'Owner', now, now, 'Free', 'Plan');
const newApp = new AppDto('id3', 'new-name', [new Permission('Owner')], now, now, 'Free', 'Plan');
let dialogs: IMock<DialogService>;
let appsService: IMock<AppsService>;

15
src/Squidex/app/shell/pages/app/left-menu.component.html

@ -1,31 +1,30 @@
<ul class="nav flex-column" *ngIf="appsState.snapshot.selectedApp; let app">
<li class="nav-item" *ngIf="app.permission !== 'Editor'">
<ul class="nav flex-column">
<li class="nav-item" *sqxPermission="'squidex.apps.{app}.schemas.?.read'">
<a class="nav-link" routerLink="schemas" routerLinkActive="active">
<i class="nav-icon icon-schemas"></i> <div class="nav-text">Schemas</div>
</a>
</li>
<li class="nav-item">
<li class="nav-item" *sqxPermission="'squidex.apps.{app}.contents.?.read'">
<a class="nav-link" routerLink="content" routerLinkActive="active">
<i class="nav-icon icon-contents"></i> <div class="nav-text">Content</div>
</a>
</li>
<li class="nav-item" *ngIf="app">
<li class="nav-item" *sqxPermission="'squidex.apps.{app}.assets.read'">
<a class="nav-link" routerLink="assets" routerLinkActive="active">
<i class="nav-icon icon-assets"></i> <div class="nav-text">Assets</div>
</a>
</li>
<li class="nav-item" *ngIf="app.permission !== 'Editor'">
<li class="nav-item" *sqxPermission="'squidex.apps.{app}.rules.read'">
<a class="nav-link" routerLink="rules" routerLinkActive="active">
<i class="nav-icon icon-rules"></i> <div class="nav-text">Rules</div>
</a>
</li>
<li class="nav-item" *ngIf="app.permission !== 'Editor'">
<li class="nav-item" *sqxPermission="'squidex.apps.{app}.backups|clients|contributors|languages|patterns|plans.read;squidex.apps.{app}.delete'">
<a class="nav-link" routerLink="settings" routerLinkActive="active">
<i class="nav-icon icon-settings"></i> <div class="nav-text">Settings</div>
</a>
</li>
<li class="nav-item" *ngIf="app.permission !== 'Editor'">
<li class="nav-item" *sqxPermission="'squidex.apps.{app}.api'">
<a class="nav-link" routerLink="api" routerLinkActive="active">
<i class="nav-icon icon-api"></i> <div class="nav-text">API</div>
</a>

2
src/Squidex/app/shell/pages/internal/profile-menu.component.html

@ -9,7 +9,7 @@
</span>
<div class="dropdown-menu" *sqxModalView="modalMenu;closeAlways:true" @fade>
<a class="dropdown-item" routerLink="/app/administration" *ngIf="isAdmin">
<a class="dropdown-item" routerLink="/app/administration" *sqxPermission="'squidex.events|restore|users.read'">
Administration
</a>

4
src/Squidex/app/shell/pages/internal/profile-menu.component.ts

@ -33,8 +33,6 @@ export class ProfileMenuComponent implements OnDestroy, OnInit {
public profileDisplayName = '';
public profileId = '';
public isAdmin = false;
public profileUrl = this.apiUrl.buildUrl('/identity-server/account/profile');
constructor(
@ -55,8 +53,6 @@ export class ProfileMenuComponent implements OnDestroy, OnInit {
this.profileId = user!.id;
this.profileDisplayName = user!.displayName;
this.isAdmin = user!.isAdmin;
this.changeDetector.detectChanges();
});
}

83
tests/Squidex.Infrastructure.Tests/Security/PermissionTests.cs

@ -24,57 +24,102 @@ namespace Squidex.Infrastructure.Security
}
[Fact]
public void Should_return_true_if_given_and_requested_permission_have_wildcards()
public void Should_check_for_non_equal_wildcard_permissions()
{
var g = new Permission("app.contents");
var r = new Permission("app.assets");
Assert.False(g.Allows(r));
Assert.False(g.Includes(r));
}
[Fact]
public void Should_check_for_equal_wildcard_permissions()
{
var g = new Permission("app.*");
var r = new Permission("app.*");
Assert.True(g.GivesPermissionTo(r));
Assert.True(g.Allows(r));
Assert.True(g.Includes(r));
}
[Fact]
public void Should_return_true_if_given_permission_equals_requested_permission()
public void Should_check_for_equal_permissions()
{
var g = new Permission("app.contents");
var r = new Permission("app.contents");
Assert.True(g.GivesPermissionTo(r));
Assert.True(g.Allows(r));
Assert.True(g.Includes(r));
}
[Fact]
public void Should_return_true_if_given_permission_is_parent_of_requested_permission()
public void Should_check_for_given_parent_of_requested()
{
var g = new Permission("app");
var r = new Permission("app.contents");
Assert.True(g.GivesPermissionTo(r));
Assert.True(g.Allows(r));
Assert.False(g.Includes(r));
}
[Fact]
public void Should_check_for_requested_parent_of_given()
{
var g = new Permission("app.contents");
var r = new Permission("app");
Assert.False(g.Allows(r));
Assert.True(g.Includes(r));
}
[Fact]
public void Should_return_true_if_given_permission_has_wildcard_for_requested_permission()
public void Should_check_for_given_wildcard_of_requested()
{
var g = new Permission("app.*");
var r = new Permission("app.contents");
Assert.True(g.GivesPermissionTo(r));
Assert.True(g.Allows(r));
Assert.True(g.Includes(r));
}
[Fact]
public void Should_return_false_if_given_permission_not_equals_requested_permission()
public void Should_check_for_requested_wildcard_of_given()
{
var g = new Permission("app.contents");
var r = new Permission("app.assets");
var r = new Permission("app.*");
Assert.False(g.GivesPermissionTo(r));
Assert.False(g.Allows(r));
Assert.True(g.Includes(r));
}
[Fact]
public void Should_return_false_if_given_permission_is_child_of_requested_permission()
public void Should_check_for_given_has_alternatives_of_requested()
{
var g = new Permission("app.contents|schemas");
var r = new Permission("app.contents");
Assert.True(g.Allows(r));
Assert.True(g.Includes(r));
}
[Fact]
public void Should_check_for_requested_has_alternatives_of_given()
{
var g = new Permission("app.contents");
var r = new Permission("app");
var r = new Permission("app.contents|schemas");
Assert.True(g.Allows(r));
Assert.False(g.GivesPermissionTo(r));
Assert.True(g.Includes(r));
}
[Fact]
@ -83,15 +128,19 @@ namespace Squidex.Infrastructure.Security
var g = new Permission("app.contents");
var r = new Permission("app.*");
Assert.False(g.GivesPermissionTo(r));
Assert.False(g.Allows(r));
Assert.True(g.Includes(r));
}
[Fact]
public void Should_return_false_if_given_requested_permission_is_null()
public void Should_check_for_requested_is_null()
{
var g = new Permission("app.contents");
Assert.False(g.GivesPermissionTo(null));
Assert.False(g.Allows(null));
Assert.False(g.Includes(null));
}
[Fact]

Loading…
Cancel
Save