Skip to main content

apiplant_core/
defaults.rs

1//! Built-in resources.
2//!
3//! `organization`, `membership`, `user`, `api_key` and `oauth_connection` exist
4//! in every app. Together they make apps multitenant out of the box: users join
5//! organisations through memberships (which also carry their **role within that
6//! organisation**), and every other resource is isolated per organisation by
7//! default.
8//!
9//! `invitation` and `auth_token` are the two tables behind the flows that reach
10//! somebody through their mailbox — being invited to an organisation,
11//! confirming an address, resetting a password. They exist in every app, and in
12//! one with no `[email]` provider they simply stay empty: the endpoints that
13//! write to them are not mounted at all.
14//!
15//! Drop a `models/<name>.toml` with the same `name` to replace a built-in and
16//! add fields or tweak permissions while keeping the machinery working.
17
18use crate::schema::Resource;
19
20/// The `organization` — the tenant. `global` because its rows *are* the
21/// organisations; membership (not an `organization_id`) decides who sees them.
22pub const ORGANIZATION_TOML: &str = r#"
23[resource]
24name = "organization"
25scope = "global"
26timestamps = true
27
28[permissions]
29list   = "member"          # organisations you belong to
30read   = "member"
31create = "authenticated"   # anyone may start one (and becomes its admin)
32update = "role:admin"      # an admin *of that organisation*
33delete = "role:admin"
34
35[fields.name]
36type = "string"
37required = true
38
39[fields.slug]
40type = "string"
41unique = true
42"#;
43
44/// A user's membership in an organisation, carrying their role there. The
45/// join table behind the N:N between `user` and `organization`.
46pub const MEMBERSHIP_TOML: &str = r#"
47[resource]
48name = "membership"
49scope = "organization"
50timestamps = true
51
52[permissions]
53list   = "member"          # members can see who else is in the org
54read   = "member"
55create = "role:admin"      # admins add members
56update = "role:admin"
57delete = "role:admin"
58
59# Built-in function: lets `create` name the person by `email` instead of by
60# `user_id`, and refuses a duplicate membership. The lookup has to happen here
61# because `user` is only readable by people you already share an org with.
62[hooks]
63before_create = "apiplant_organization_join"
64
65[fields.user_id]
66type = "reference"
67references = "user"
68required = true
69on_delete = "cascade"      # a deleted account takes its memberships with it
70
71[fields.organization_id]
72type = "reference"
73references = "organization"
74required = true
75
76[fields.role]
77type = "string"            # the member's *primary* role here, e.g. "admin".
78                           # Further roles are `membership_role` rows; a
79                           # `role:` permission is checked against all of them.
80"#;
81
82/// A single role held by a membership.
83///
84/// Roles are a set, not a slot: someone can be a `billing` *and* a `support`
85/// person without either displacing the other, which one column cannot express.
86/// [`MEMBERSHIP_TOML`]'s `role` stays as the member's **primary** role — it is
87/// what existing apps and hook contexts read — and these rows are the rest.
88/// Together they are the roles a `role:` permission is checked against.
89///
90/// `admin` is special: it satisfies every role check in its organisation
91/// without needing a row per role, so granting someone `admin` grants them
92/// everything the app defines.
93pub const MEMBERSHIP_ROLE_TOML: &str = r#"
94[resource]
95name = "membership_role"
96scope = "organization"
97timestamps = true
98
99[permissions]
100list   = "member"          # members can see who holds what
101read   = "member"
102create = "role:admin"      # admins grant roles
103update = "role:admin"
104delete = "role:admin"
105
106[fields.membership_id]
107type = "reference"
108references = "membership"
109required = true
110on_delete = "cascade"      # a removed member takes their roles with them
111
112[fields.organization_id]
113type = "reference"
114references = "organization"
115required = true
116
117[fields.role]
118type = "string"
119required = true
120"#;
121
122/// The default `user`: global (users are shared across organisations) with
123/// email + password auth. Extend via `models/users.toml`.
124pub const USER_TOML: &str = r#"
125[resource]
126name = "user"
127scope = "global"
128timestamps = true
129
130[permissions]
131list   = "member"          # people you share an organisation with
132read   = "member"
133create = "public"          # registration
134update = "owner"
135delete = "private"
136
137[auth]
138identity_field = "email"
139password_field = "password_hash"
140oauth_providers = []
141
142[fields.email]
143type = "string"
144required = true
145unique = true
146max_length = 320
147
148[fields.password_hash]
149type = "string"
150hidden = true
151
152# When the address was confirmed. Null means unconfirmed — which only *stops*
153# anyone when `[auth] require_email_verification` is on, so an app with no
154# mailer carries the column and never looks at it.
155[fields.email_verified_at]
156type = "timestamp"
157
158[fields.email_verified_at.admin]
159visible = false
160
161[fields.display_name]
162type = "string"
163"#;
164
165/// An invitation to join an organisation, addressed to someone who may not have
166/// an account yet.
167///
168/// This is the table behind `POST <base>/auth/invitations`. The emailed link
169/// carries a token whose **hash** is what lives here, for the same reason an
170/// API key's does: a leaked database should not be a pile of working links.
171///
172/// It is org-scoped, so an admin only ever sees the invitations to their own
173/// organisation, and `read`/`create` are `role:admin` — the endpoints that a
174/// person *without* an account uses (previewing a link, accepting it) are not
175/// CRUD on this resource at all, and reach the row through the token they were
176/// sent rather than through the API's permissions.
177pub const INVITATION_TOML: &str = r#"
178[resource]
179name = "invitation"
180scope = "organization"
181timestamps = true
182
183[permissions]
184list   = "role:admin"      # admins see who is still pending
185read   = "role:admin"
186create = "private"         # issued by POST /auth/invitations, never by hand:
187                           # a row written directly has no token to send
188update = "private"
189delete = "role:admin"      # revoking is deleting the row
190
191[fields.email]
192type = "string"
193required = true
194max_length = 320
195
196[fields.role]
197type = "string"            # the role they will hold once they accept
198
199[fields.token_hash]
200type = "string"
201required = true
202unique = true
203hidden = true
204
205[fields.invited_by]
206type = "reference"
207references = "user"
208on_delete = "set_null"
209
210[fields.expires_at]
211type = "timestamp"
212required = true
213
214# Set when the invitation is used. A row with this filled in is history, kept
215# so "who let them in, and when" survives the membership being edited later.
216[fields.accepted_at]
217type = "timestamp"
218"#;
219
220/// A single-use token sent to an address to prove someone reads it.
221///
222/// Both address confirmation and password reset are the same shape — mint a
223/// secret, mail it, accept it once, before it expires — so they are one table
224/// distinguished by `kind` rather than two that would drift apart.
225///
226/// Entirely `private`: every row is created and spent by the framework's own
227/// endpoints, and there is nothing here anybody should read over the API. The
228/// plaintext exists only in the message that was sent.
229pub const AUTH_TOKEN_TOML: &str = r#"
230[resource]
231name = "auth_token"
232scope = "global"
233timestamps = true
234
235[admin]
236label = "Auth token"
237plural = "Auth tokens"
238visible = false
239
240[permissions]
241list   = "private"
242read   = "private"
243create = "private"
244update = "private"
245delete = "private"
246
247[fields.user_id]
248type = "reference"
249references = "user"
250required = true
251on_delete = "cascade"      # a deleted account takes its live links with it
252
253[fields.kind]
254type = "string"
255required = true            # "email_verification" or "password_reset"
256
257[fields.token_hash]
258type = "string"
259required = true
260unique = true
261hidden = true
262
263[fields.expires_at]
264type = "timestamp"
265required = true
266
267# Set the moment the token is spent, so a link in a mailbox works exactly once.
268[fields.used_at]
269type = "timestamp"
270"#;
271
272/// Default `api_key` resource (global). A valid key authenticates as its owner.
273pub const API_KEY_TOML: &str = r#"
274[resource]
275name = "api_key"
276scope = "global"
277timestamps = true
278
279# "Api key" is what titleising the name produces, and it is not how anybody
280# writes it.
281[admin]
282label = "API key"
283plural = "API keys"
284
285[permissions]
286list   = "owner"
287read   = "owner"
288create = "authenticated"
289update = "private"
290delete = "owner"
291
292[fields.name]
293type = "string"
294
295[fields.token_hash]
296type = "string"
297required = true
298unique = true
299hidden = true
300
301[fields.owner_id]
302type = "reference"
303references = "user"
304required = true
305"#;
306
307/// Default `oauth_connection` resource (global) linking a user to a provider.
308pub const OAUTH_TOML: &str = r#"
309[resource]
310name = "oauth_connection"
311scope = "global"
312timestamps = true
313
314[permissions]
315list   = "owner"
316read   = "owner"
317create = "private"
318update = "private"
319delete = "owner"
320
321[fields.provider]
322type = "string"
323required = true
324
325[fields.provider_user_id]
326type = "string"
327required = true
328
329[fields.owner_id]
330type = "reference"
331references = "user"
332required = true
333"#;
334
335// --- billing ------------------------------------------------------------
336//
337// The five `billing_*` resources exist only in an app whose `[payments]`
338// section names a provider — see [`billing_builtins`]. They are prefixed for
339// the same reason the built-in functions are: `product` and `price` are words
340// an ordinary app wants for its own domain, and a framework that took them
341// would be taking them from a shop that sells things.
342//
343// The split between them is the split Stripe already makes, and copying it is
344// deliberate: a **product** is the thing you sell, a **price** is one way to
345// pay for it (monthly, yearly, one-off), and a product with three prices is
346// three ways to buy one thing rather than three things. Because these are
347// ordinary resources, the catalogue is CRUD — a `role:admin` can add a plan
348// from the dashboard, and the hooks below mirror it into Stripe — while
349// `billing_customer`, `billing_subscription` and `billing_payment` are
350// `private`: Stripe decides what is paid for and the webhook writes it down.
351
352/// A thing the app sells. Global: the catalogue is the same for every tenant.
353///
354/// Writable by an admin, and every write is mirrored into Stripe by the
355/// [`apiplant_stripe_product`] built-in, so a plan added in the dashboard
356/// exists in Stripe before the row is committed. `stripe_product_id` is what
357/// that hook fills in; nothing else should.
358///
359/// [`apiplant_stripe_product`]: https://docs.rs/apiplant-server
360pub const BILLING_PRODUCT_TOML: &str = r#"
361[resource]
362name = "billing_product"
363scope = "global"
364timestamps = true
365
366[admin]
367label = "Product"
368plural = "Products"
369
370[permissions]
371list   = "public"          # a pricing page is read by people with no account
372read   = "public"
373create = "role:admin"
374update = "role:admin"
375delete = "role:admin"
376
377# Mirror the catalogue into Stripe. Creating the row creates the product;
378# renaming it renames it; archiving it (active = false) archives it there too.
379[hooks]
380before_create = "apiplant_stripe_product"
381before_update = "apiplant_stripe_product"
382
383[fields.name]
384type = "string"
385required = true
386
387[fields.description]
388type = "text"
389
390# Off the price list without deleting the history that points at it. Stripe
391# calls this "archived", and a product with live subscriptions cannot be
392# deleted in either system — only stopped from being bought again.
393[fields.active]
394type = "boolean"
395default = true
396
397# Free-form facts the app checks when deciding what a plan may do: seat
398# limits, feature flags, an internal tier name. Copied to Stripe as metadata
399# so an operator reading either system sees the same thing.
400[fields.features]
401type = "json"
402
403[fields.stripe_product_id]
404type = "string"
405unique = true
406
407[fields.stripe_product_id.admin]
408readonly = true
409help = "Filled in by Stripe when the product is first saved."
410"#;
411
412/// One way to pay for a [product](BILLING_PRODUCT_TOML): an amount, a
413/// currency, and either a billing interval (a subscription) or none (a one-off
414/// payment).
415///
416/// Amounts are in the currency's **smallest unit** — 1000 is €10.00 — because
417/// that is the only representation that is exact, and it is what Stripe,
418/// every card network and every accountant's ledger already use. A float here
419/// would be a rounding error waiting for a big enough invoice.
420pub const BILLING_PRICE_TOML: &str = r#"
421[resource]
422name = "billing_price"
423scope = "global"
424timestamps = true
425
426[admin]
427label = "Price"
428plural = "Prices"
429
430[permissions]
431list   = "public"
432read   = "public"
433create = "role:admin"
434update = "role:admin"    # only `active` and presentation: see the hook
435delete = "role:admin"
436
437[hooks]
438before_create = "apiplant_stripe_price"
439before_update = "apiplant_stripe_price"
440
441[fields.product_id]
442type = "reference"
443references = "billing_product"
444required = true
445on_delete = "cascade"      # a deleted product takes its price list with it
446
447[fields.nickname]
448type = "string"            # "Monthly", "Yearly (2 months free)"
449
450# The charge, in the currency's smallest unit: 1000 = €10.00 = $10.00.
451[fields.unit_amount]
452type = "big_int"
453required = true
454
455[fields.unit_amount.admin]
456help = "In the smallest unit of the currency — 1000 is 10.00."
457
458[fields.currency]
459type = "string"
460max_length = 3             # ISO 4217; empty takes [payments] currency
461
462# How often it recurs: "month", "year", "week", "day", or empty for a price
463# that is charged once. This is what decides whether buying it starts a
464# subscription or takes a single payment.
465[fields.interval]
466type = "string"
467
468[fields.interval.admin]
469options = ["|One-off", "day|Daily", "week|Weekly", "month|Monthly", "year|Yearly"]
470
471# Charge every N intervals — 3 with interval = "month" is quarterly.
472[fields.interval_count]
473type = "integer"
474default = 1
475
476# Days before the first charge. 0 (the default) starts billing immediately.
477[fields.trial_days]
478type = "integer"
479default = 0
480
481# Whether the amount already includes tax. "exclusive" (the default) means tax
482# is added on top, which is what [payments] automatic_tax computes; "inclusive"
483# means the amount is the total and the tax is worked out from within it.
484[fields.tax_behavior]
485type = "string"
486default = "exclusive"
487
488[fields.tax_behavior.admin]
489options = ["exclusive|Tax added on top", "inclusive|Tax included in the amount"]
490
491[fields.active]
492type = "boolean"
493default = true
494
495# Stripe prices are immutable once created: changing an amount there means
496# creating a new price and archiving the old one, which is exactly what the
497# hook does. The id therefore points at whichever price object is current.
498[fields.stripe_price_id]
499type = "string"
500unique = true
501
502[fields.stripe_price_id.admin]
503readonly = true
504help = "Filled in by Stripe. Changing the amount creates a new price and archives the old one."
505"#;
506
507/// The organisation as Stripe knows it: one row per tenant, holding the
508/// customer id every charge and subscription hangs off.
509///
510/// Org-scoped and `private`. It is written by the checkout endpoint and the
511/// webhook, never by hand — a second customer for one organisation would
512/// split its payment methods, its invoices and its tax status across two
513/// records that neither system would ever reconcile.
514pub const BILLING_CUSTOMER_TOML: &str = r#"
515[resource]
516name = "billing_customer"
517scope = "organization"
518timestamps = true
519
520[admin]
521label = "Billing customer"
522plural = "Billing customers"
523
524[permissions]
525list   = "role:admin"      # billing is the admins' business
526read   = "role:admin"
527create = "private"
528update = "private"
529delete = "private"
530
531[fields.stripe_customer_id]
532type = "string"
533required = true
534unique = true
535
536# Where invoices and receipts go. Defaults to the address of whoever first
537# started a checkout, and is changed from the Stripe portal.
538[fields.email]
539type = "string"
540max_length = 320
541
542[fields.name]
543type = "string"
544
545# The buyer's VAT/GST number, once they have given one. Kept because it is
546# what turns a taxed sale into a reverse-charge one, and support gets asked
547# about it more than anything else on this table.
548[fields.tax_id]
549type = "string"
550
551# Country Stripe places the customer in, for tax. Two-letter ISO 3166-1.
552[fields.tax_country]
553type = "string"
554max_length = 2
555
556# Everything else Stripe holds about the customer, as last delivered. Kept so
557# an operator can answer a billing question without opening two dashboards.
558[fields.details]
559type = "json"
560
561[fields.details.admin]
562visible = false
563"#;
564
565/// A live (or lapsed) subscription: one organisation paying for one
566/// [price](BILLING_PRICE_TOML) on a schedule.
567///
568/// `private` for writes and readable by any member, which is what makes it
569/// usable as an entitlement check — a function asking "is this org on a paid
570/// plan" reads a row through the same permissions as everything else. Stripe
571/// is the source of truth and the webhook is what writes here: a row edited by
572/// hand would say the customer is paying when they are not.
573pub const BILLING_SUBSCRIPTION_TOML: &str = r#"
574[resource]
575name = "billing_subscription"
576scope = "organization"
577timestamps = true
578
579[admin]
580label = "Subscription"
581plural = "Subscriptions"
582
583[permissions]
584list   = "member"          # members can see what plan they are on
585read   = "member"
586create = "private"         # started by POST /billing/checkout
587update = "private"         # written by the Stripe webhook
588delete = "private"
589
590[fields.price_id]
591type = "reference"
592references = "billing_price"
593on_delete = "set_null"     # an archived price outlives its row
594
595[fields.customer_id]
596type = "reference"
597references = "billing_customer"
598on_delete = "cascade"
599
600# Stripe's own words: "trialing", "active", "past_due", "canceled",
601# "incomplete", "incomplete_expired", "unpaid", "paused". Anything checking
602# for a paying customer wants "active" or "trialing" — see the `payments`
603# guide for why "past_due" is a judgement call and not a status.
604[fields.status]
605type = "string"
606required = true
607
608[fields.quantity]
609type = "integer"
610default = 1
611
612[fields.current_period_end]
613type = "timestamp"
614
615# Set when the customer has asked to stop but has already paid through the end
616# of the period: still entitled, not renewing.
617[fields.cancel_at_period_end]
618type = "boolean"
619default = false
620
621[fields.trial_ends_at]
622type = "timestamp"
623
624[fields.canceled_at]
625type = "timestamp"
626
627[fields.stripe_subscription_id]
628type = "string"
629required = true
630unique = true
631
632[fields.stripe_subscription_id.admin]
633readonly = true
634"#;
635
636/// One payment that happened: a one-off purchase, or an invoice a
637/// subscription generated.
638///
639/// A ledger, not a state machine — a row per attempt Stripe told us about,
640/// kept whether it succeeded or not, because "the card was declined on the
641/// 3rd" is the answer to most billing questions.
642pub const BILLING_PAYMENT_TOML: &str = r#"
643[resource]
644name = "billing_payment"
645scope = "organization"
646timestamps = true
647
648[admin]
649label = "Payment"
650plural = "Payments"
651
652[permissions]
653list   = "role:admin"
654read   = "role:admin"
655create = "private"
656update = "private"
657delete = "private"
658
659[fields.customer_id]
660type = "reference"
661references = "billing_customer"
662on_delete = "cascade"
663
664[fields.price_id]
665type = "reference"
666references = "billing_price"
667on_delete = "set_null"
668
669[fields.subscription_id]
670type = "reference"
671references = "billing_subscription"
672on_delete = "set_null"     # null for a one-off
673
674# What was actually taken, in the smallest unit, and what of it was tax.
675[fields.amount]
676type = "big_int"
677required = true
678
679[fields.tax_amount]
680type = "big_int"
681default = 0
682
683[fields.currency]
684type = "string"
685max_length = 3
686
687# "succeeded", "pending", "failed", "refunded".
688[fields.status]
689type = "string"
690required = true
691
692[fields.description]
693type = "string"
694
695# The buyer's own receipt, hosted by Stripe. Worth storing: it is the link
696# support is asked for, and it outlives the session that produced it.
697[fields.receipt_url]
698type = "string"
699max_length = 2048
700
701[fields.paid_at]
702type = "timestamp"
703
704[fields.stripe_payment_intent_id]
705type = "string"
706unique = true
707
708[fields.stripe_invoice_id]
709type = "string"
710"#;
711
712/// Every webhook Stripe has delivered, by its event id.
713///
714/// This table is the idempotency: Stripe retries a delivery until it is
715/// acknowledged and may deliver the same event twice regardless, so the
716/// handler inserts the id first and does the work only if the insert was new.
717/// Without it a retried `invoice.paid` is a second row in the ledger and a
718/// customer who appears to have paid twice.
719///
720/// Entirely `private`, and hidden from the dashboard: it is a log with one
721/// reader, which is the handler itself.
722pub const BILLING_EVENT_TOML: &str = r#"
723[resource]
724name = "billing_event"
725scope = "global"
726timestamps = true
727
728[admin]
729label = "Billing event"
730plural = "Billing events"
731visible = false
732
733[permissions]
734list   = "private"
735read   = "private"
736create = "private"
737update = "private"
738delete = "private"
739
740[fields.stripe_event_id]
741type = "string"
742required = true
743unique = true
744
745[fields.kind]
746type = "string"
747required = true            # "checkout.session.completed", "invoice.paid", …
748
749# When the work finished. A row with this null is an event that arrived and
750# then failed to process — Stripe will retry it, and this is where to look
751# when it stops retrying.
752[fields.processed_at]
753type = "timestamp"
754
755[fields.error]
756type = "text"
757
758[fields.payload]
759type = "json"
760hidden = true
761"#;
762
763/// The name → embedded-TOML table of built-ins, in dependency order so foreign
764/// keys resolve (organization and user before membership/api_key/oauth).
765pub fn builtins() -> Vec<(&'static str, &'static str)> {
766    vec![
767        ("organization", ORGANIZATION_TOML),
768        ("user", USER_TOML),
769        ("membership", MEMBERSHIP_TOML),
770        ("membership_role", MEMBERSHIP_ROLE_TOML),
771        ("api_key", API_KEY_TOML),
772        ("oauth_connection", OAUTH_TOML),
773        ("invitation", INVITATION_TOML),
774        ("auth_token", AUTH_TOKEN_TOML),
775    ]
776}
777
778/// The billing resources, in dependency order (product before price, customer
779/// before subscription before payment).
780///
781/// Unlike [`builtins`] these are conditional: an app gets them when its
782/// `[payments]` section names a provider, and not otherwise. Five tables and
783/// five sets of endpoints is a lot to hand an app that never takes money, and
784/// unlike `invitation` — which is two columns behind a feature most apps do
785/// eventually turn on — a catalogue nobody sells from is just noise in the
786/// dashboard.
787///
788/// Migrations are additive, so switching payments *off* leaves the tables
789/// where they are: the data outlives the config, which is the right way round
790/// for anything that recorded money changing hands.
791pub fn billing_builtins() -> Vec<(&'static str, &'static str)> {
792    vec![
793        ("billing_product", BILLING_PRODUCT_TOML),
794        ("billing_price", BILLING_PRICE_TOML),
795        ("billing_customer", BILLING_CUSTOMER_TOML),
796        ("billing_subscription", BILLING_SUBSCRIPTION_TOML),
797        ("billing_payment", BILLING_PAYMENT_TOML),
798        ("billing_event", BILLING_EVENT_TOML),
799    ]
800}
801
802/// Parse one built-in by its embedded TOML. Panics on a malformed built-in —
803/// that would be a bug in this crate, caught by the test below.
804pub fn parse_builtin(toml_src: &str) -> Resource {
805    let r: Resource = toml::from_str(toml_src).expect("built-in resource TOML is valid");
806    r.validate().expect("built-in resource is valid");
807    r
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813
814    #[test]
815    fn all_builtins_parse() {
816        for (name, src) in builtins().into_iter().chain(billing_builtins()) {
817            let r = parse_builtin(src);
818            assert_eq!(r.meta.name, name);
819        }
820    }
821
822    /// The prefix is what keeps a shop's own `product` model out of the
823    /// framework's way, so it holds for every billing resource, not just the
824    /// two that would have collided today.
825    #[test]
826    fn every_billing_resource_is_namespaced() {
827        for (name, _) in billing_builtins() {
828            assert!(name.starts_with("billing_"), "`{name}` is unprefixed");
829        }
830    }
831
832    /// A `reference` that names a resource nothing declares migrates to a
833    /// foreign key against a table that isn't there, and the app fails to
834    /// boot. Every target here is either a billing resource or a core one.
835    #[test]
836    fn billing_references_resolve_within_the_app() {
837        let known: Vec<&str> = builtins()
838            .into_iter()
839            .chain(billing_builtins())
840            .map(|(name, _)| name)
841            .collect();
842        for (name, src) in billing_builtins() {
843            let resource = parse_builtin(src);
844            for (field, spec) in &resource.fields {
845                if let Some(target) = &spec.references {
846                    assert!(
847                        known.contains(&target.as_str()),
848                        "{name}.{field} points at an unknown `{target}`"
849                    );
850                }
851            }
852        }
853    }
854}