Osy#betaRun it on Osyrin: osyrin.com

Reference / Security

restricted rows and purpose reads (Restriction, [PurposeRead])

Restriction.Restrict(row, category, until) · Restriction.Lift(row) · Restriction.SetUntil(row, until) · Restriction.Purge(category, before) · Restriction.Count(category) · [PurposeRead("category")]

Keep a row only for a stated purpose: Restriction.Restrict stamps it with a privacy category and the end of its period, and from then on it is absent from every read — staff, admins and the platform's own system reads included — except inside a function declared [PurposeRead("<category>")], whose every read of it is written to the access trail.

preview2 examples compiled by CIsecurityprivacygdprretention

Summary#

GDPR lets a company keep data a person asked to have erased when the law or a legal claim requires it — an invoiced buyer's name stays in the books for seven years. Kept data must then be used for that purpose and nothing else. Restriction.Restrict(row, "shop.invoiced-buyer", until) does that: the row stays stored, and is absent from every ordinary read. A function declared [PurposeRead("shop.invoiced-buyer")] — the bookkeeping export — still reads it, and each such read is recorded in the access trail.

Signature#

bool Restriction.Restrict(row, string category, DateTime? until)   // until: null = until it is lifted
bool Restriction.Lift(row)                                         // only a purpose read holds the row
bool Restriction.SetUntil(row, DateTime? until)                    // move a RESTRICTED row's period; an ordinary one stays ordinary
int  Restriction.Purge(string category, DateTime before)           // deletes rows whose period ended before `before`
int  Restriction.Count(string category)                            // how many rows are kept for it

[PurposeRead("<category>")] <function>                             // reads that category's restricted rows

Description#

What "absent" means. A restricted row is not returned by a query, a count, an Any, a by-key lookup, a collection, a traversal or a set-based update; a reference to it reads as an empty row (every field null), as a reference to a row you may not read does; its memory entries are removed (and derived again if the restriction is lifted); an export sink receives its personal fields as null. This holds for every principal and for the platform's own system reads — it is not a security rule a grant could widen, it is a property of the row.

Only an entity holding personal data can be restricted — one with a field classified Personal.* (personal data (Classification(Personal.X))), or in a hierarchy with one. Restricting anything else is refused at compile.

The stamp is the platform's. No source reads or writes it; Restrict places it, Lift removes it, Purge deletes what has reached the end of its period. A row kept until: null is never purged — it waits for Lift.

Moving a period: SetUntil. Inside a purpose read, restricted and ordinary rows are read side by side, and nothing says which is which — so Restrict on each would restrict the ordinary ones too. Restriction.SetUntil(row, until) moves the end of a row's period only when the row is already restricted, and answers whether it was; an ordinary row stays ordinary. A legal hold keeps a person's kept rows from the purge this way (until: null), and gives them their period back when it is lifted (Legal holds — nothing a dispute needs is erased or purged while it lasts).

Purpose reads. Inside a [PurposeRead("<category>")] function — and every function it calls — reads admit the rows restricted for that one category beside ordinary rows; another category's rows stay absent. When the run ends, every restricted row it read is written to AccessLog with AccessType = "PurposeRead", the category, the function and who ran it. A purpose read is never unlogged: the app must use Osysharp.Observability;, or the function is refused before it reads anything. [PurposeRead] cannot be combined with [AllowAnonymous] or [AuthMethod] — the trail must be able to name who read.

⚠ What a purpose function returns is ordinary data in its caller's hands. Return what the purpose needs (the PDF for the books), not the row.

Examples#

app Books { use Osysharp.Observability; }

entity Voucher {
  [Required, MaxLength(40)] string Number;
  decimal Amount;
  [Classification(Personal.Contact), MaxLength(200)] string? Buyer;
  security { allow read, create, update, delete when IsAuthenticated || IsAnonymous; }
}

/// The person asked to be erased; the voucher is kept, for the books only, until its period ends.
bool KeepForTheBooks(Voucher v, DateTime until) { return Restriction.Restrict(v, "app.vouchers", until); }

/// The bookkeeping export — the one place the kept buyer is read. Every read is logged.
[PurposeRead("app.vouchers")]
string BuyerForTheBooks(string number) { return Voucher.Single(v => v.Number == number).Buyer ?? ""; }
[Test]
void A_kept_voucher_is_read_only_for_its_purpose() {
  var v = new Voucher { Number = "V-1", Amount = 120m, Buyer = "Ann Book" };
  UnitOfWork.Commit();
  KeepForTheBooks(v, DateTime.UtcNow.AddYears(7));
  UnitOfWork.Commit();
  Assert.Equal(0, Voucher.Count());
  Assert.Equal("Ann Book", BuyerForTheBooks("V-1"));
  Assert.Equal(1, Restriction.Count("app.vouchers"));
}

See also#

Related

personal data (Classification(Personal.X))

Mark a field as personal data once, on the property, with a level of the core `Personal` enum. From then on the audit…

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…

audit read access (app.Audit)

`app.Audit` configures the app's audit trails — WHO may read each one, and whether it is recorded at all. An app that…