coaster
coaster copied to clipboard
Inbound and outbound anchors
In the process of defining abstractions around our core data models, we've arrived on the following three:
-
Documents, which are the concern of the mixin classes provided in
coaster.sqlalchemy
. - Principals, which were originally just user objects, but increasingly cover a spread of user-like objects with properties such as authorship and ownership. These are discussed in hasgeek/lastuser#91.
- Anchors, which are points of interaction with the user. Anchors may be inbound, for users to communicate with the app, or outbound, for the app to communicate with a user.
Anchors do not indicate the existence of a user/principal, but activity that connects two or more anchors can help infer the presence of a user or other principal. Anchors also are not necessarily database objects. By their nature they are also not guaranteed to exist long term.
Inbound anchors (↓)
These are anchors that allow the user to connect to the app. Examples include:
-
Browser sessions. May or may not be authenticated with a user login (and thereby associated with a principal), but browser sessions can independently create an activity trail. We use this in Hasjob for tracking A/B testing impressions, and previously allowed creation of job posts as well.
-
Private URLs. A URL that is not published to the public can be used to grant an access role. Hasjob used to use it to allow edits to a job post from anyone with the edit URL. Boxoffice currently uses it to manage ticket assignments. Lastuser uses them to send password reset links. (Access control currently happens in the view layer, but #156 describes a mechanism for pushing it into RoleMixin.)
-
Access tokens. An access token allows a principal to delegate limited access to an anonymous principal (usually a client app that presents it as a Bearer token, but the exact identity of the caller cannot be determined).
-
OTP. A short lived code that confirms some activity, similar to one of the use cases for private URLs.
Outbound anchors (↑)
-
Email address. Allows sending an email to what is presumably a principal at the receiving end.
-
Phone number. Allows sending an SMS or making a phone call to a presumed principal.
-
Push notification/Browser socket. Allows communication with a client app or browser session.
Outbound anchors like email/phone aren't necessarily verified identifiers. Verification requires a multi-step process as outlined below.
Inferring principals
Activity that creates a combination of an outbound anchor with an inbound anchor (in that order) can confirm that outbound anchor, implying the existence of a principal (if one is not already known). Examples:
- Email ↑ + verification link ↓: If the link is accessed, it confirms the email address.
- Browser session ↓ + phone ↑ + OTP ↓ + browser session ↓: Associates the phone anchor with the browser session, and by extension with any principal associated with the browser session.
- Document + email ↑ + verification link ↓: Publishes the document.
Linking
From these examples, documents, principals and anchors can all be linked to each other.
While the implementation details are beyond the scope of Coaster, it should provide a basic framework as it has done for documents and principals (since #154).
Example of how the URL inbound anchor could be used:
@app.route('/mydocuments/<doc>/edit/<secret_key>', methods=['GET', 'POST'])
@load_model(MyDocument, {'name': 'doc'}, 'rawdoc', kwargs=True)
@url_anchor(document='rawdoc', anchor='kwargs.secret_key')
def document_edit(rawdoc, kwargs):
doc = rawdoc.access_for(current_auth.user, current_auth.anchors)
# Further activity is with `doc`
form = MyDocumentForm(obj=doc)
if form.validate_on_submit():
form.populate_obj(doc)
return redirect(doc.url_for('view'), code=303)
return render_form(form, title="Edit document")
This one is lacking an outright mechanism by which to issue a 401 Forbidden. It instead limits edit access to whatever is provided by a valid anchor, using logic determined by the model's roles_for
method.
The TeamEmail
model in hasgeek/lastuser#185 describes an anchor chain. While that model is likely to be deprecated by the switch to principals in hasgeek/lastuser#91, here's the outline:
- An outbound anchor (
TeamEmail
) is sent an email containing what is known to be a shared link. - The shared link (an inbound URL anchor) is now associated with the browser session (another inbound anchor) and by extension with any principal or document associated with that inbound anchor.
- Since the shared link is assumed to be for more than one recipient, the associations formed from it (browser sessions or principals) are assumed to form a group principal (currently
Organization
orTeam
).
Contrast this with the very similar flow used for merging user accounts:
- An outbound anchor (OAuth login) is invoked to send the user away. The anchor could be associated with a
User
principal (login as a user) or anOrganization
principal (connect an organization's Twitter account). - The user returns with an inbound anchor (OAuth token grant) and this is (a) exchanged for an OAuth token, and (b) associated with the browser session. (Unless the OAuth token allows sending messages to the user, it's not an outbound anchor.)
- If there is an existing
User
principal attached to the browser session, one of two things happens:- If the first outbound anchor was for a
User
, it's assumed there is a conflictingUser
account and the user is redirected to the merge flow. At present this is the only flow available. - If the first outbound anchor was for an
Organization
orTeam
, the OAuth token and the currentUser
principal are now associated with that principal. At present this flow is not available.
- If the first outbound anchor was for a
These use cases suggest that a Flow
model that connects documents, principals and anchors needs to be defined, distinct from just anchors.
The @url_anchor
decorator above feels a bit superfluous. It adds nothing to what @app.route
achieves. It doesn't even help validate the URL. If the access key is defined on the document model, @load_models
can do the validation by itself.
While we need the URL anchor to map into a flow, it needs a more useful form.
Inbound anchors are all tokens of one form or the other. A cookie token, an Authorisation header token, a URL query parameter token or some other.
There's no need for a new term like "inbound anchor". Let's just call them tokens.
This frees up the word "anchor" for the outbound variety. An anchor is where you can contact someone. An anchor is not a subsidiary of a user, but a first class entity that may be currently affiliated to one or more users, but could previously be affiliated to someone else.
In Lastuser, that will mean a new EmailAnchor
(or Email
) model that has no dependencies, while the existing UserEmail
and UserEmailClaim
will be recast as join models, transferring their natural keys over.
Further, since an anchor contains PII and is subject to privacy/right to be forgotten concerns (including in the form of an unsubscribe), the anchor should not be indexed using the natural key, but using a hash of it. The hash will need an uncommon salt to defend against reversal using rainbow tables. Unfortunately, the salt here cannot be random as with passwords. It has to either derive from the natural key itself, or should be fixed for the data table, since the salt must be known before the record can be looked up.
Lastuser was merged into Funnel in 2020. Email anchors were implemented as an EmailAddress
model in https://github.com/hasgeek/funnel/pull/714. A phone anchor model is pending. "Forgotten" email anchors (for eg, a user requested a block) are tracked using a hash. This is harder for phone numbers because the limited search space is vulnerable to a rainbow table attack (only 10 digits in India, with a smaller active subset). Phone number hashing will require more entropy for protection.