Difference between revisions of "API Context"
| Line 1: | Line 1: | ||
If you're working on or with a service in a SocialStack API, you'll very often need a Context. The context object exists to simply indicate who is asking for the execution - a default projects context contains their role, locale and the user information if there is any. The context object is partial like everything else, meaning you can extend it with a context extension. Context extensions can be a very effective way to add tiers of authentication or make complex permission rules simpler. | If you're working on or with a service in a SocialStack API, you'll very often need a Context. The context object exists to simply indicate who is asking for the execution - a default projects context contains their role, locale and the user information if there is any. The context object is partial like everything else, meaning you can extend it with a context extension. Context extensions can be a very effective way to add tiers of authentication or make complex permission rules simpler. The context is the bedrock of the permissions system as the state in the context will directly define what will be permitted or not except for when DataOptions.IgnorePermissions is specified in your service calls to Update/Create/Delete/Where. | ||
= Context extensions = | = Context extensions = | ||
Revision as of 01:17, 15 September 2022
If you're working on or with a service in a SocialStack API, you'll very often need a Context. The context object exists to simply indicate who is asking for the execution - a default projects context contains their role, locale and the user information if there is any. The context object is partial like everything else, meaning you can extend it with a context extension. Context extensions can be a very effective way to add tiers of authentication or make complex permission rules simpler. The context is the bedrock of the permissions system as the state in the context will directly define what will be permitted or not except for when DataOptions.IgnorePermissions is specified in your service calls to Update/Create/Delete/Where.
Context extensions
You may want to extend the context object if your users authenticate twice in some way, or if having rapid access to some commonly used ID is convenient. For example, somebody might login using their user account and then select an organisation. If we put the selected organisation into the context, the organisation ID will be part of the user's cookie and the selected organisation can then be known by any code with access to the context which, in a SocialStack API, is essentially everywhere.
Another example of a situation to extend the context is the user's locale. We could look up the locale to use from the user row, but sometimes people might not be logged in and want to view the site in some other language. We could derive the locale from other secondary sources such as the IP or Accept-Language headers but those are slow and not necessarily user controllable. So, locale makes sense as an extra value in the context which can be set separately from the user, and we can set its initial value from those slower sources as an initial best guess. For this reason and because everything in SocialStack is modular, the localisation module (Api/Translations) itself has a Context.cs context extension which is almost identical to what you see below.
A template for a context extension is below.
- Add it to your project, usually as Api/Entity/Context.cs (e.g. Api/Organisations/Context.cs)
- Case sensitive replace {Entity} with your entity name (e.g. Organisation)
- Case sensitive replace {entity} with your lowercase entity name (e.g. organisation)
using System.Threading.Tasks;
using Api.Startup;
using Api.Database;
using Api.Users;
using System;
namespace Api.Contexts
{
public partial class Context
{
private static {Entity}Service _{entity}s;
/// <summary>
/// Underlying {entity} ID.
/// </summary>
private uint _{entity}Id = 1;
/// <summary>
/// The current {entity} or the site default.
/// </summary>
public uint {Entity}Id
{
get
{
return _{entity}Id;
}
set
{
if (value == 0)
{
value = 1;
}
_{entity} = null;
_{entity}Id = value;
}
}
/// <summary>
/// The full {entity} object, if it has been requested.
/// </summary>
private {Entity} _{entity};
/// <summary>
/// Gets the {entity} for this context.
/// </summary>
/// <returns></returns>
public async ValueTask<{Entity}> Get{Entity}()
{
if (_{entity} != null)
{
return _{entity};
}
if (_{entity}s == null)
{
_{entity}s = Services.Get<{Entity}Service>();
}
// Get the user now:
_{entity} = await _{entity}s.Get(this, {Entity}Id, DataOptions.IgnorePermissions);
if (_{entity} == null)
{
// Dodgy {entity} in the cookie. {Entity} #1 always exists.
return await _{entity}s.Get(this, 1, DataOptions.IgnorePermissions);
}
return _{entity};
}
}
}
namespace Api.Permissions{
/// <summary>
/// Functions that can be used in a filter. They are invoked during compilation and can output type specific things.
/// </summary>
public static partial class FilterFunctions
{
/// <summary>
/// True if either the object is a PubKey and is a direct match, or if this {entity} is the creator user.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <typeparam name="ID"></typeparam>
/// <param name="node"></param>
/// <param name="ast"></param>
public static FilterTreeNode<T, ID> IsSelf{Entity}<T, ID>(MemberFilterTreeNode<T, ID> node, FilterAst<T, ID> ast)
where T : Content<ID>, new()
where ID : struct, IConvertible, IEquatable<ID>, IComparable<ID>
{
if (node.Args.Count != 0)
{
throw new PublicException("IsSelf{Entity} in a filter call takes 0 arguments", "filter_invalid");
}
var op = new OpFilterTreeNode<T, ID>() {
Operation = "="
};
// Context {Entity}Id field:
op.B = new MemberFilterTreeNode<T, ID>()
{
Name = "{Entity}Id",
OnContext = true
}.Resolve(ast);
if (typeof(T) == typeof({Entity}s.{Entity}))
{
// Id=context.{Entity}Id
op.A = new MemberFilterTreeNode<T, ID>() {
Name = "Id"
}.Resolve(ast);
}
else
{
// If the object does not have a {Entity}Id field (e.g. it's just a regular Content<>), then return a const false.
if (!typeof(UserCreatedContent<ID>).IsAssignableFrom(typeof(T)))
{
// A false:
return new BoolFilterTreeNode<T, ID>();
}
// {Entity}Id=context.{Entity}Id
op.A = new MemberFilterTreeNode<T, ID>()
{
Name = "{Entity}Id"
}.Resolve(ast);
}
return op;
}
}
}
Setting a context field
You can update a context field by simply setting the ID:
context.OrganisationId = id;
And then you must output the context. If you set things during the regular user authentication flow (Events.UserOnAuthenticate, Events.Logout, Events.ContextAfterAnonymous) then the context will be outputted anyway and you can skip this step. Outputting the context is what generates a new valid cookie for your user and will output JSON into the response which can then be used to update the "session" in your frontend. The following is an example endpoint from Api/Translate/LocaleController.cs as a very simple context update:
/// <summary>
/// GET /v1/locale/set/2/
/// Sets locale by its ID.
/// </summary>
[HttpGet("set/{id}")]
public virtual async ValueTask Set([FromRoute] uint id)
{
var context = await Request.GetContext();
// Set locale ID:
context.LocaleId = id;
await OutputContext(context);
}
Do of course consider security - you may need to ensure that the user first has access to the thing being used before actually setting it in to the context like this.
Advanced Context Permissions
If you've created a context extension using the above template, it also added a filter function to your permission system. If your entity is called "Organisation" then you will have an IsSelfOrganisation() permission filter function which you'll be able to use in any of your permission grants.
These filters allow you to ensure that someone is authenticated as {organisation x} before they can create/ update/ delete content owned by {organisation x}. Note that for it to be a strong authentication, you must of course ensure that the context field is safely set in the first place - for example through first checking that the logged in user is from the specified organisation and can authenticate as it.
The filter works by checking either a column called {Entity}Id, or the Id directly (if it is targeting an {Entity}). Carrying on with the Organisation example above, let's say an Organisation has a list of Document objects. On the Document entity is public uint OrganisationId;. The permission rule for document_list is set as IsSelfOrganisation() and this filter function returns true if a Document has an OrganisationId which matches the OrganisationId in the current context. IsSelfOrganisation() on organisation_list returns true for only the organisation with the Id that matches the one in the current context.