Summary#
Some pages are for people who will never sign in: the reader who clicks "manage your mail" at the foot of a letter,
the customer paying an order from a mail, the visitor confirming an address. What they hold is a LINK, and the link is
the permission. A grant is that link, declared as a type:
using Osysharp.Ui;
entity Subscriber {
[MaxLength(200)] string Email;
bool Weekly;
security {
allow read by Preferences; // a Preferences link reads the row it was made for
allow update Weekly by Preferences; // …and may change this one column of it
}
}
/// What the foot of every letter links to.
grant Preferences for Subscriber { Expires = TimeSpan.FromDays(30); }
/// Made when the letter is sent, by code that can read the subscriber.
string PreferencesLink(Subscriber s) { return $"{App.BaseUrl}/preferences/{Preferences.For(s)}"; }
// Called by the page. The platform has checked `link` before the first line runs.
[AllowAnonymous] string AddressOf(Preferences link) { return link.Row.Email; }
[AllowAnonymous] bool GetsWeekly(Preferences link) { return link.Row.Weekly; }
[AllowAnonymous] void SetWeekly(Preferences link, bool on) { link.Row.Weekly = on; UnitOfWork.Commit(); }
[Route("/preferences/{link}")]
[AllowAnonymous]
component PreferencesPage(Preferences link) {
string address = "";
bool weekly = false;
on mount { address = AddressOf(link); weekly = GetsWeekly(link); }
// The switch has already written `weekly` when this runs — the action only saves it.
action Save() { SetWeekly(link, weekly); }
render {
Stack(gap: 4) {
Text($"Mail for {address}");
Switch("The weekly letter", value: weekly, onToggle: Save);
}
}
}Nobody signs in and nobody needs to. The link reads Subscriber rows exactly as far as the rule says — the one row it was made for — and nothing else in the app: the visitor is still anonymous for every other entity.
Signature#
grant G for Entity { Expires = TimeSpan.FromDays(n); } // also FromHours / FromMinutes / FromSeconds; at most 400 days
security {
allow read by G; // the row G was made for
allow read by G where Subscriber == G.Row; // rows of ANOTHER entity, reached from G's row
allow update Col1, Col2 by G; // writes are opt-in, per column
}
G.For(row) // mint a link (a G value; "$"{…}"" gives the text to put in a URL)
G.RevokeAll(row) // every link ever made for this row stops working, at once
G.From(text) // check link text you received some other way; throws the refusal if it is not valid
void F(G link) { … } // a parameter: checked before the body runs; `link.Row` is the row
[Route("/x/{link}")] component P(G link) { … } // a page: checked in routing, before the page is served
app.Ui = new AppUi { InvalidLinkSurface = LinkGone }; // what a refused link shows (optional)Description#
What a grant admits#
A grant admits one row of one entity, and only where a rule says so. allow read by Preferences; on Subscriber
means: a caller holding a valid Preferences link reads the Subscriber row that link was made for. It does not read
other subscribers — a Subscriber.Count() under the link answers 1, not the size of the table — and a grant of another
kind for the same row (a Receipt link) satisfies nothing that names Preferences.
To reach rows of another entity from the grant's row, name the column that connects them:
entity Subscriber {
[MaxLength(200)] string Email;
[ForeignKey(Subscriber)] Membership[] Memberships;
security { allow read by Preferences; }
}
entity Membership {
[Required] Subscriber Subscriber;
[MaxLength(60)] string List;
security { allow read by Preferences where Subscriber == Preferences.Row; }
}
grant Preferences for Subscriber { Expires = TimeSpan.FromDays(365); }
[AllowAnonymous] List<string> ListsOf(Preferences link) {
return link.Row.Memberships.Select(m => m.List).ToList();
}A by rule may carry any where you like; if it does not name G.Row itself, the platform adds Id == G.Row
for you, so allow read by G where Active; still reads only the grant's row.
Read-only unless you say otherwise#
A grant reads. It writes only the columns an allow update … by G names — allow update Weekly by Preferences; lets
the link change Weekly and nothing else on the row; a write to Email through the same link is refused. A grant
never creates or deletes the row it names: a link is permission to act on that row, and "remove it" is the app's
decision, taken by a function the link calls (the mailing-lists kit's LeaveWithLink is one).
Adding a row to the row a link names#
A link may ADD a row of ANOTHER entity that hangs off its row — a reader's message to the desk from the link in their mail, the browser a continue link is opened in — when that entity's rule says so, naming the column that reaches the granted row:
entity Reader {
[MaxLength(200)] string Email;
security { allow read by Preferences; }
}
entity Comment {
[Required] Reader Reader;
[MaxLength(500)] string Text;
security {
allow create by Preferences where Reader == Preferences.Row; // only on the row the link was made for
allow read by Preferences where Reader == Preferences.Row;
}
}
grant Preferences for Reader { Expires = TimeSpan.FromDays(30); }
[AllowAnonymous] void Comment(Preferences link, string text) {
new Comment { Reader = link.Row, Text = text };
UnitOfWork.Commit();
}The new row is judged on its own values like any create: a comment the same holder aims at ANOTHER reader is refused.
allow create by G on the grant's OWN entity stays a compile error — that row does not exist yet, so there is nothing
for the link to name.
Field masks still apply#
A grant does not lift a deny read — the holder is anonymous, and deny read Secret when IsAnonymous; hides Secret
from them exactly as it hides it from any other visitor. The link reaches the ROW, never past the row's own rules.
A rule that compares with user covers the holder too: with nobody signed in Owner != user is unknown, and a
deny whose condition is unknown applies ([[security-entity-security#unknown-deny]]). So when the same entity is also
read by ordinary visitors — a bag that belongs to a visit until somebody signs in — one rule masks what an owned row
says about its owner, and leaves an unowned one alone:
deny read BornOn when !IsStaff where Owner != null && Owner != user; // a link's holder, or anyone else not the ownerDoes holding a link sign anyone in?#
Holding a link does not sign anybody in. Session.CurrentUser is null, IsAnonymous is true, and every entity whose
rules do not name the grant is as closed to the holder as to anyone else. So a grant cannot be escalated into an
account, and a page taking one needs [AllowAnonymous] like any public page.
Making a link#
G.For(row) makes a link for row, and only for a row the code making it can read now — the link hands the row on,
so only someone who holds the row may hand it on. Typically that is a workflow or a send (which runs as the app), or a
signed-in owner sharing their own row. $"{G.For(row)}" is the text to put in a URL; it is URL-safe and starts g1..
A link is signed with the app's own key: nothing to configure, no secret to set, and a link made by one app opens nothing in another, even for a row with the same id.
Expiry and taking links back#
Every grant says how long its links work — a grant without Expires is a compile error, because "forever" should be
a choice you can see. At most 400 days.
G.RevokeAll(row) makes every G link ever made for that row stop working, immediately — call it when the row is
erased, when an order is paid, when a reader asks for their links to be reset. It takes back G's links only: a row
may carry links of several grants that end at different moments — an order's payment link is taken back once it is
paid, while the link to the order itself, in the confirmation mail, goes on working — so taking back every kind is
one RevokeAll per grant. It is committed on its
own the moment it runs, so a rollback elsewhere cannot undo it. A new G.For(row) afterwards works again. And a link to
a row that has been deleted stops working by itself. What RevokeAll changes is kept in one small platform table,
Osysharp.RowGrants, which an app carries once it (or a package it uses) declares a grant; an app that stops declaring
its grant keeps the table, so a link it took back stays taken back if the grant returns.
Taking a link#
Declare the parameter as the grant: void Leave(Preferences link). The platform checks the link before the first line
of the body — signature, app, grant, expiry, revocation, the row still existing — and anything else throws one
refusal, "This link has expired or is not valid.", the same words whatever was wrong (telling "expired" from
"forged" would tell a guesser which guess was nearly right). Inside the body, link.Row is the row.
The link counts for that call. A function that hands a link to a helper does not itself read under it afterwards, and on a page the next request carries no link unless it passes one again.
Link text that arrives some other way — a webhook body, an HTTP endpoint's path segment — becomes a checked grant with
G.From(text). Its .Row is read under the grant; the grant is not held after From returns.
How does a page take a grant?#
A page parameter typed as a grant must be a segment of the page's route ([Route("/preferences/{link}")]), and the
platform checks it in ROUTING, before the page is served: a link that does not check out never reaches your component.
The visitor gets a 404 carrying the app's own page for it — app.Ui = new AppUi { InvalidLinkSurface = LinkGone };,
served inside that page's [Layout] — or, when the app names none, a plain page saying This link has expired or is
not valid.
entity Subscriber {
[MaxLength(200)] string Email;
security { allow read by Preferences; }
}
grant Preferences for Subscriber { Expires = TimeSpan.FromDays(30); }
[AllowAnonymous] string AddressOf(Preferences link) { return link.Row.Email; }
[Route("/preferences/{link}")] [AllowAnonymous]
component PreferencesPage(Preferences link) {
string address = "";
on mount { address = AddressOf(link); }
render { Text(address); }
}
[Route("/link-gone")] [AllowAnonymous]
component LinkGone() {
render { Text("That link has run out. The one at the foot of our latest letter always works."); }
}
app.Ui = new AppUi { InvalidLinkSurface = LinkGone };A component cannot read link.Row itself — the browser holds only the link's text. Pass the grant to a server function
that reads it, as AddressOf does above; the compiler says so, with that shape, if you write link.Row in a component.
Pages that take a grant are served with Referrer-Policy: no-referrer and Cache-Control: no-store, so the link in
the address does not leave in a Referer header or sit in a shared cache, and the client router always loads them fresh
from the server, so the check above always runs. The platform's own logs never carry a link: every g1.… in a
logged path is written g1.[redacted].
What the compiler refuses#
| You wrote | You are told |
|---|---|
allow read by Preferencs; | `by Preferencs` names no declared grant. Did you mean `by Preferences`? |
Preferences.For(order) where Preferences is for Reader | `Preferences.For` takes the Reader the link is for — and the grant an Order link would need |
grant Pay for Ordr { … } | grant 'Pay' is for 'Ordr', and no entity has that name |
component P(Preferences link) on a route without {link} | the parameter must be a segment of the route |
grant G for E { } | says nothing about how long its links work |
new Preferences { … } | a grant is not built with new — Preferences.For(row) makes one |
allow read by Preferences; on an entity that is not the grant's | the rule to write: allow read by Preferences where Reader == Preferences.Row; |
allow create by Preferences; on the grant's own entity | a create has no row for the link to name — and, to let the link ADD a row, the create to write on the other entity |
link.Row in a component | read it in a server function, with the function to write |
Examples#
The worked example is the mailing-lists kit — its "manage your mail" page and its confirmation link are grants; see
The MailingLists kit — a newsletter done properly. The shop kit uses three more. The link to an ORDER — grant OrderLink for Order { Expires = TimeSpan.FromDays(365); } — is what every mail about an order, the page a payment provider sends the customer back to
(/paid/{link}/…) and the receipt carry, instead of the order's reference: the order is read by the account it was
placed from, the visit that placed it, staff, and whoever holds its link, and a reference opens nothing. Several
grants are admitted by ONE rule, beside the rest of who may read the row:
allow read when IsAnonymous || IsAuthenticated
where Account == user || VisitId == Visitor.Id || IsShopPacker || Id == OrderLink.Row || Id == Pay.Row;Its payment link (grant Pay for Order { Expires = TimeSpan.FromDays(7); }, a [Route("/pay/{pay}")] component PayPage(Pay pay) calling [AllowAnonymous] functions that take Pay pay) is taken back with Pay.RevokeAll(order)
when the order is paid or called off — and the OrderLink in the confirmation mail goes on working, because a
RevokeAll takes back its own grant's links only. And the abandoned-bag reminder's "Back to your bag" (grant ResumeBag for Bag, read on the bag and its lines, the owner's details on the bag masked as above, taken back
once the bag is ordered or emptied).
See also#
- security { } — the
security { }block abyrule lives in - public pages (what a signed-out visitor can see and do) — the other way to let a signed-out visitor in, for data meant for everyone
- routes and pages —
[Route]routes and their parameters - UI surfaces (app.Ui) —
app.Ui, whereInvalidLinkSurfaceis set - The MailingLists kit — a newsletter done properly — grants in a real kit