I know that ServiceStack provides a RequiredRole attribute to control permissions, however, this does not fully work for my use case. I have a website with a lot of user-generated content. Users can only edit documents they have explicit permissions to. Permissions are controlled per object or group of the object. So, if a user is an admin of a group then they can edit all documents managed by that group.
What is the best design pattern to control access to a request on a per object per user
basis? I want to approach this with as DRY a methodology as possible as it will affect 95% of all my API endpoints.
Also, can this be integrated with FluentValidation and return appropriate HTTP responses?
Many thanks,
Richard.
I use per object permissions in my ServiceStack applications. Effectively this is an Access-Control-List (ACL).
I have created a Working Self Hosted Console Example which you can fork on GitHub.
I use the database structure shown in the diagram below, whereby resources in my database such as documents, files, contacts etc (any resource I want to protect) are all given an ObjectType
id.
The permissions table contains rules that apply to specific users, specific groups, specific objects and specific object types, and is flexible to accept them in combinations, where a null
value will be treated like a wildcard.
I find the easiest way to handle them is to use a request filter attribute. With my solution I simply add a couple of attributes to my request route declaration:
[RequirePermission(ObjectType.Document)]
[Route("/Documents/{Id}", "GET")]
public class DocumentRequest : IReturn<string>
{
[ObjectId]
public int Id { get; set; }
}
[Authenticate]
public class DocumentService : Service
{
public string Get(DocumentRequest request)
{
// We have permission to access this document
}
}
I have a filter attribute call RequirePermission
, this will perform the check to see that the current user requesting the DTO DocumentRequest
has access to the Document
object whose ObjectId
is given by the property Id
. That's all there is to wiring up the checking on my routes, so it's very DRY.
RequirePermission
request filter attribute:The job of testing for permission is done in the filter attribute, before reaching the service's action method. It has the lowest priority which means it will run before validation filters.
This method will get the active session, a custom session type (details below), which provides the active user's Id and the group Ids they are permitted to access. It will also determine the objectId if any from the request.
It determines the object id by examining the request DTO's properties to find the value having the [ObjectId]
attribute.
With that information it will query the permission source to find the most appropriate permission.
public class RequirePermissionAttribute : Attribute, IHasRequestFilter
{
readonly int objectType;
public RequirePermissionAttribute(int objectType)
{
// Set the object type
this.objectType = objectType;
}
IHasRequestFilter IHasRequestFilter.Copy()
{
return this;
}
public void RequestFilter(IRequest req, IResponse res, object requestDto)
{
// Get the active user's session
var session = req.GetSession() as MyServiceUserSession;
if(session == null || session.UserAuthId == 0)
throw HttpError.Unauthorized("You do not have a valid session");
// Determine the Id of the requested object, if applicable
int? objectId = null;
var property = requestDto.GetType().GetPublicProperties().FirstOrDefault(p=>Attribute.IsDefined(p, typeof(ObjectIdAttribute)));
if(property != null)
objectId = property.GetValue(requestDto,null) as int?;
// You will want to use your database here instead to the Mock database I'm using
// So resolve it from the container
// var db = HostContext.TryResolve<IDbConnectionFactory>().OpenDbConnection());
// You will need to write the equivalent 'hasPermission' query with your provider
// Get the most appropriate permission
// The orderby clause ensures that priority is given to object specific permissions first, belonging to the user, then to groups having the permission
// descending selects int value over null
var hasPermission = session.IsAdministrator ||
(from p in Db.Permissions
where p.ObjectType == objectType && ((p.ObjectId == objectId || p.ObjectId == null) && (p.UserId == session.UserAuthId || p.UserId == null) && (session.Groups.Contains(p.GroupId) || p.GroupId == null))
orderby p.ObjectId descending, p.UserId descending, p.Permitted, p.GroupId descending
select p.Permitted).FirstOrDefault();
if(!hasPermission)
throw new HttpError(System.Net.HttpStatusCode.Forbidden, "Forbidden", "You do not have permission to access the requested object");
}
public int Priority { get { return int.MinValue; } }
}
When the permissions are read from the permission table, the highest priority permission is used to determine if they have access. The more specific the permission entry is, the higher the priority it has when the results are ordered.
Permissions matching the current user have greater priority than general permissions for all users i.e where UserId == null
. Similarly a permission for the specifically requested object has higher priority than the general permission for that object type.
User specific permissions take precedence over group permissions. This means, that a user can be granted access by a group permission but be denied access at user level, or vice versa.
Where the user belongs to a group that allows them access to a resource and to another group that denies them access, then the user will have access.
The default rule is to deny access.
In my example code above I have used this linq query to determine if the user has permission. The example uses a mocked database, and you will need to substitute it with your own provider.
session.IsAdministrator ||
(from p in Db.Permissions
where p.ObjectType == objectType &&
((p.ObjectId == objectId || p.ObjectId == null) &&
(p.UserId == session.UserAuthId || p.UserId == null) &&
(session.Groups.Contains(p.GroupId) || p.GroupId == null))
orderby p.ObjectId descending, p.UserId descending, p.Permitted, p.GroupId descending
select p.Permitted).FirstOrDefault();
I have used a custom session object to store the group memberships, these are looked up and added to the session when the user is authenticated.
// Custom session handles adding group membership information to our session
public class MyServiceUserSession : AuthUserSession
{
public int?[] Groups { get; set; }
public bool IsAdministrator { get; set; }
// The int value of our UserId is converted to a string!?! :( by ServiceStack, we want an int
public new int UserAuthId {
get { return base.UserAuthId == null ? 0 : int.Parse(base.UserAuthId); }
set { base.UserAuthId = value.ToString(); }
}
// Helper method to convert the int[] to int?[]
// Groups needs to allow for null in Contains method check in permissions
// Never set a member of Groups to null
static T?[] ConvertArray<T>(T[] array) where T : struct
{
T?[] nullableArray = new T?[array.Length];
for(int i = 0; i < array.Length; i++)
nullableArray[i] = array[i];
return nullableArray;
}
public override void OnAuthenticated(IServiceBase authService, ServiceStack.Auth.IAuthSession session, ServiceStack.Auth.IAuthTokens tokens, System.Collections.Generic.Dictionary<string, string> authInfo)
{
// Determine UserId from the Username that is in the session
var userId = Db.Users.Where(u => u.Username == session.UserName).Select(u => u.Id).First();
// Determine the Group Memberships of the User using the UserId
var groups = Db.GroupMembers.Where(g => g.UserId == userId).Select(g => g.GroupId).ToArray();
IsAdministrator = groups.Contains(1); // Set IsAdministrator (where 1 is the Id of the Administrator Group)
Groups = ConvertArray<int>(groups);
base.OnAuthenticated(authService, this, tokens, authInfo);
}
}
I hope you find this example useful. Let me know if anything is unclear.
Also, can this be integrated with FluentValidation and return appropriate HTTP responses?
You shouldn't try and do this in the validation handler, because it is not validation. Checking if you have permission is a verification process. If you require to check something against a specific value in a datasource you are no longer performing validation. See this other answer of mine which also covers this.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With