Osy#betaRun it on Osyrin: osyrin.com

Reference / Security

grants (a link that opens one row)

grant Preferences for Subscriber { Expires = TimeSpan.FromDays(30); }

A `grant` is a link the platform signs that lets whoever holds it reach ONE row of ONE entity, for a while, without signing in — the "manage your mail" link at the foot of a newsletter, a confirmation link, a pay-this-order link. Declare it once (`grant G for E { Expires = … }`), admit it in the entity's rules (`allow read by G;`), mint it (`G.For(row)`), and take it as a typed parameter (`void Leave(G link)`, `component Page(G link)`). The platform checks the signature, the app, the grant, the expiry and the revocation stamp before any of your code runs.

preview4 examples compiled by CIsecurityauthorizationanonymouslinks

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 owner

Does 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 wroteYou 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'sthe rule to write: allow read by Preferences where Reader == Preferences.Row;
allow create by Preferences; on the grant's own entitya 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 componentread 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#

Related

security { }

The rules that decide who may read and write an entity's rows. A where clause filters by the row (the owner sees their…

public pages (what a signed-out visitor can see and do)

A public page, its public data, its public actions and its live updates are four separate declarations…

principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads

Two built-in `when` predicates say who a request is: `IsAuthenticated` is a signed-in user, `IsAnonymous` is an…

routes and pages

How a component becomes a page: it declares a route with `[Route("/catalog/{slug}")]`, and navigating to a matching…

UI surfaces (app.Ui)

`app.Ui` nominates the app's own components for the "system surfaces" the platform would otherwise render a bare…

The MailingLists kit — a newsletter done properly

`Osysharp.MailingLists` keeps an app's mailing lists: sign-up with double opt-in, consent recorded word for word, a…