1use crate::schema::Resource;
25
26pub const ORGANIZATION_TOML: &str = r#"
29[resource]
30name = "organization"
31scope = "global"
32timestamps = true
33
34[permissions]
35list = "member" # organisations you belong to
36read = "member"
37create = "authenticated" # anyone may start one (and becomes its admin)
38update = "role:admin" # an admin *of that organisation*
39delete = "role:admin"
40
41[fields.name]
42type = "string"
43required = true
44
45[fields.slug]
46type = "string"
47unique = true
48
49# A logo, as a URL a browser can fetch. Nothing sets it — an organisation is not
50# handed to us by an identity provider the way a person is — so it is here for
51# an app to fill and for every interface to read: the dashboard's workspace
52# switcher shows it in place of the initials it would otherwise draw.
53[fields.avatar_url]
54type = "string"
55max_length = 1024
56"#;
57
58pub const MEMBERSHIP_TOML: &str = r#"
61[resource]
62name = "membership"
63scope = "organization"
64timestamps = true
65
66[permissions]
67list = "member" # members can see who else is in the org
68read = "member"
69create = "role:admin" # admins add members
70update = "role:admin"
71delete = "role:admin"
72
73# Built-in function: lets `create` name the person by `email` instead of by
74# `user_id`, and refuses a duplicate membership. The lookup has to happen here
75# because `user` is only readable by people you already share an org with.
76[hooks]
77before_create = "apiplant_organization_join"
78
79[fields.user_id]
80type = "reference"
81references = "user"
82required = true
83on_delete = "cascade" # a deleted account takes its memberships with it
84
85[fields.organization_id]
86type = "reference"
87references = "organization"
88required = true
89
90[fields.role]
91type = "string" # the member's *primary* role here, e.g. "admin".
92 # Further roles are `membership_role` rows; a
93 # `role:` permission is checked against all of them.
94"#;
95
96pub const MEMBERSHIP_ROLE_TOML: &str = r#"
108[resource]
109name = "membership_role"
110scope = "organization"
111timestamps = true
112
113[permissions]
114list = "member" # members can see who holds what
115read = "member"
116create = "role:admin" # admins grant roles
117update = "role:admin"
118delete = "role:admin"
119
120[fields.membership_id]
121type = "reference"
122references = "membership"
123required = true
124on_delete = "cascade" # a removed member takes their roles with them
125
126[fields.organization_id]
127type = "reference"
128references = "organization"
129required = true
130
131[fields.role]
132type = "string"
133required = true
134"#;
135
136pub const USER_TOML: &str = r#"
139[resource]
140name = "user"
141scope = "global"
142timestamps = true
143
144[permissions]
145list = "member" # people you share an organisation with
146read = "member"
147create = "public" # registration
148update = "owner"
149delete = "private"
150
151[auth]
152identity_field = "email"
153password_field = "password_hash"
154oauth_providers = []
155
156[fields.email]
157type = "string"
158required = true
159unique = true
160max_length = 320
161
162[fields.password_hash]
163type = "string"
164hidden = true
165
166# When the address was confirmed. Null means unconfirmed — which only *stops*
167# anyone when `[auth] require_email_verification` is on, so an app with no
168# mailer carries the column and never looks at it.
169[fields.email_verified_at]
170type = "timestamp"
171
172[fields.email_verified_at.admin]
173visible = false
174
175# What to call somebody, and what to show beside their name.
176#
177# Both are ordinary nullable columns nothing requires — an app that has no use
178# for either can leave them empty or drop them by replacing this model. They are
179# here because they are what almost every app wants and what every identity
180# provider hands over: a sign-in through [`[oauth]`](crate::config::OAuthConfig)
181# fills them in, so an account that arrives that way arrives with a name and a
182# picture rather than an email address and a blank.
183[fields.display_name]
184type = "string"
185
186[fields.avatar_url]
187type = "string"
188max_length = 1024
189
190# True when the address above is one apiplant invented rather than one somebody
191# gave it.
192#
193# A sign-in through a provider that releases no address — X, today — still has
194# to put *something* in the identity column, which is required and unique. It
195# writes `<provider>_<id>@oauth.invalid`, at a TLD RFC 2606 reserves so that it
196# can never resolve.
197#
198# This flag is here, in every app, because the framework is the one inventing
199# that value: fabricating an address and not recording that it was fabricated
200# leaves an app to find out by watching mail bounce. With it, "we have an
201# address for this person" stops being the same question as "there is a string
202# in the email column" — which is the question a welcome email, a newsletter
203# and a CSV export all actually mean to ask.
204[fields.email_placeholder]
205type = "boolean"
206default = false
207
208[fields.email_placeholder.admin]
209visible = false
210"#;
211
212pub const INVITATION_TOML: &str = r#"
225[resource]
226name = "invitation"
227scope = "organization"
228timestamps = true
229
230[permissions]
231list = "role:admin" # admins see who is still pending
232read = "role:admin"
233create = "private" # issued by POST /auth/invitations, never by hand:
234 # a row written directly has no token to send
235update = "private"
236delete = "role:admin" # revoking is deleting the row
237
238[fields.email]
239type = "string"
240required = true
241max_length = 320
242
243[fields.role]
244type = "string" # the role they will hold once they accept
245
246[fields.token_hash]
247type = "string"
248required = true
249unique = true
250hidden = true
251
252[fields.invited_by]
253type = "reference"
254references = "user"
255on_delete = "set_null"
256
257[fields.expires_at]
258type = "timestamp"
259required = true
260
261# Set when the invitation is used. A row with this filled in is history, kept
262# so "who let them in, and when" survives the membership being edited later.
263[fields.accepted_at]
264type = "timestamp"
265"#;
266
267pub const AUTH_TOKEN_TOML: &str = r#"
277[resource]
278name = "auth_token"
279scope = "global"
280timestamps = true
281
282[admin]
283label = "Auth token"
284plural = "Auth tokens"
285visible = false
286
287[permissions]
288list = "private"
289read = "private"
290create = "private"
291update = "private"
292delete = "private"
293
294[fields.user_id]
295type = "reference"
296references = "user"
297required = true
298on_delete = "cascade" # a deleted account takes its live links with it
299
300[fields.kind]
301type = "string"
302required = true # "email_verification" or "password_reset"
303
304[fields.token_hash]
305type = "string"
306required = true
307unique = true
308hidden = true
309
310[fields.expires_at]
311type = "timestamp"
312required = true
313
314# Set the moment the token is spent, so a link in a mailbox works exactly once.
315[fields.used_at]
316type = "timestamp"
317"#;
318
319pub const API_KEY_TOML: &str = r#"
321[resource]
322name = "api_key"
323scope = "global"
324timestamps = true
325
326# "Api key" is what titleising the name produces, and it is not how anybody
327# writes it.
328[admin]
329label = "API key"
330plural = "API keys"
331
332[permissions]
333list = "owner"
334read = "owner"
335create = "authenticated"
336update = "private"
337delete = "owner"
338
339[fields.name]
340type = "string"
341
342[fields.token_hash]
343type = "string"
344required = true
345unique = true
346hidden = true
347
348[fields.owner_id]
349type = "reference"
350references = "user"
351required = true
352"#;
353
354pub const OAUTH_TOML: &str = r#"
372[resource]
373name = "oauth_connection"
374scope = "global"
375timestamps = true
376
377# Readable by whoever it belongs to — `GET <base>/oauth_connection` is "my
378# linked accounts", with no filter in the request saying so — and written by
379# nobody but the framework.
380#
381# `delete` is **private**, which is the one that looks wrong and is not:
382# removing a connection has an invariant that a row deletion cannot see. An
383# account with no password and no second provider becomes permanently
384# unreachable the moment its last connection goes, so unlinking is
385# `DELETE <base>/auth/oauth/{provider}`, which checks what else is left and
386# refuses the last one. Leaving `delete = "owner"` here would put a door beside
387# that check with nothing behind it but the same table.
388[permissions]
389list = "owner"
390read = "owner"
391create = "private"
392update = "private"
393delete = "private"
394
395[fields.provider]
396type = "string"
397required = true
398
399# The provider's own immutable id for this person — GitHub's numeric id,
400# Google's `sub`, X's user id. Never a username: GitHub and X both let people
401# change theirs and let the freed name be taken by somebody else, so an account
402# keyed on one would hand the new owner the old owner's account.
403[fields.provider_user_id]
404type = "string"
405required = true
406
407# `provider:provider_user_id`, so the pair can carry a UNIQUE constraint — a
408# single column is what `unique` can express. It is what makes two simultaneous
409# first-time sign-ins from one GitHub account produce one user instead of two:
410# the loser's insert conflicts, and it reads back the winner's row rather than
411# creating a second account.
412[fields.provider_key]
413type = "string"
414unique = true
415max_length = 320
416
417[fields.owner_id]
418type = "reference"
419references = "user"
420required = true
421on_delete = "cascade"
422
423# What the provider last said about them. `email_verified` is the field the
424# account-matching rule hangs on, so it records what the *provider* claimed
425# rather than what this app would like to be true.
426[fields.email]
427type = "string"
428max_length = 320
429
430[fields.email_verified]
431type = "boolean"
432default = false
433
434[fields.display_name]
435type = "string"
436
437[fields.avatar_url]
438type = "string"
439max_length = 1024
440
441[fields.last_login_at]
442type = "timestamp"
443"#;
444
445pub const OAUTH_STATE_TOML: &str = r#"
457[resource]
458name = "oauth_state"
459scope = "global"
460timestamps = true
461
462# Machinery, like `auth_token`: rows appear for ninety seconds while somebody
463# reads a consent screen and are never worth looking at afterwards, so the
464# dashboard does not offer a screen for them.
465[admin]
466label = "OAuth sign-in"
467plural = "OAuth sign-ins"
468visible = false
469
470# Nothing may reach this table over the API — not even the person whose sign-in
471# it is. Every column on it is either a secret or a decision that stops being
472# safe the moment a client can change it, and its only reader is the callback
473# endpoint, which goes to the table directly.
474[permissions]
475list = "private"
476read = "private"
477create = "private"
478update = "private"
479delete = "private"
480
481[fields.provider]
482type = "string"
483required = true
484
485# SHA-256 of the `state` parameter, not the parameter. The value itself travels
486# in a URL, through browser history and the provider's logs; only its hash is
487# kept, so this table leaking does not let anybody finish a flow in progress.
488# SHA-256 rather than argon2 for the same reason an API key's hash is: the
489# callback looks the row up *by* this column.
490[fields.state_hash]
491type = "string"
492required = true
493unique = true
494hidden = true
495
496# The PKCE verifier, where the provider supports PKCE. Only its SHA-256 travels
497# with the authorize redirect, so intercepting that redirect — or the code that
498# comes back on it — is not enough to redeem anything.
499[fields.verifier]
500type = "string"
501hidden = true
502
503# Repeated verbatim in the token request, because the provider compares the two
504# and refuses on any difference. Recorded rather than recomputed so that a
505# config change mid-flow cannot strand a sign-in.
506[fields.redirect_uri]
507type = "string"
508max_length = 1024
509
510# Set when the flow was started by somebody already signed in: this is "connect
511# my GitHub", not "sign me in". It is decided here, while holding that account's
512# session, and never read from the callback — which is the difference between
513# linking an account you can prove is yours and linking any account whose id you
514# can name.
515[fields.link_user_id]
516type = "reference"
517references = "user"
518on_delete = "cascade"
519
520# Where to send the browser afterwards. Only ever a path on this site; see the
521# `return_to` handling in the oauth routes.
522[fields.return_to]
523type = "string"
524max_length = 1024
525
526# How the caller asked to be given the token, overriding `[oauth]
527# token_delivery` for this one flow. Empty means "however the app is
528# configured".
529#
530# It is recorded rather than read from the callback for the ordinary reason:
531# the callback is a request the provider makes, and nothing about it is the
532# app's word. A first-party client — the admin dashboard, say — knows how it
533# wants to receive a token, and this is where it says so.
534[fields.token_delivery]
535type = "string"
536max_length = 16
537
538[fields.expires_at]
539type = "timestamp"
540required = true
541
542# Stamped by the callback. A code may be redeemed once, and so may the state
543# that authorised it: a second callback carrying the same one is a double-click
544# or an attack, and is refused either way.
545[fields.used_at]
546type = "timestamp"
547"#;
548
549pub const BILLING_PRODUCT_TOML: &str = r#"
575[resource]
576name = "billing_product"
577scope = "global"
578timestamps = true
579
580[admin]
581label = "Product"
582plural = "Products"
583
584[permissions]
585list = "public" # a pricing page is read by people with no account
586read = "public"
587create = "role:admin"
588update = "role:admin"
589delete = "role:admin"
590
591# Mirror the catalogue into Stripe. Creating the row creates the product;
592# renaming it renames it; archiving it (active = false) archives it there too.
593[hooks]
594before_create = "apiplant_stripe_product"
595before_update = "apiplant_stripe_product"
596
597[fields.name]
598type = "string"
599required = true
600
601[fields.description]
602type = "text"
603
604# Off the price list without deleting the history that points at it. Stripe
605# calls this "archived", and a product with live subscriptions cannot be
606# deleted in either system — only stopped from being bought again.
607[fields.active]
608type = "boolean"
609default = true
610
611# Free-form facts the app checks when deciding what a plan may do: seat
612# limits, feature flags, an internal tier name. Copied to Stripe as metadata
613# so an operator reading either system sees the same thing.
614[fields.features]
615type = "json"
616
617[fields.stripe_product_id]
618type = "string"
619unique = true
620
621[fields.stripe_product_id.admin]
622readonly = true
623help = "Filled in by Stripe when the product is first saved."
624"#;
625
626pub const BILLING_PRICE_TOML: &str = r#"
635[resource]
636name = "billing_price"
637scope = "global"
638timestamps = true
639
640[admin]
641label = "Price"
642plural = "Prices"
643
644[permissions]
645list = "public"
646read = "public"
647create = "role:admin"
648update = "role:admin" # only `active` and presentation: see the hook
649delete = "role:admin"
650
651[hooks]
652before_create = "apiplant_stripe_price"
653before_update = "apiplant_stripe_price"
654
655[fields.product_id]
656type = "reference"
657references = "billing_product"
658required = true
659on_delete = "cascade" # a deleted product takes its price list with it
660
661[fields.nickname]
662type = "string" # "Monthly", "Yearly (2 months free)"
663
664# The charge, in the currency's smallest unit: 1000 = €10.00 = $10.00.
665[fields.unit_amount]
666type = "big_int"
667required = true
668
669[fields.unit_amount.admin]
670help = "In the smallest unit of the currency — 1000 is 10.00."
671
672[fields.currency]
673type = "string"
674max_length = 3 # ISO 4217; empty takes [payments] currency
675
676# How often it recurs: "month", "year", "week", "day", or empty for a price
677# that is charged once. This is what decides whether buying it starts a
678# subscription or takes a single payment.
679[fields.interval]
680type = "string"
681
682[fields.interval.admin]
683options = ["|One-off", "day|Daily", "week|Weekly", "month|Monthly", "year|Yearly"]
684
685# Charge every N intervals — 3 with interval = "month" is quarterly.
686[fields.interval_count]
687type = "integer"
688default = 1
689
690# Days before the first charge. 0 (the default) starts billing immediately.
691[fields.trial_days]
692type = "integer"
693default = 0
694
695# Whether the amount already includes tax. "exclusive" (the default) means tax
696# is added on top, which is what [payments] automatic_tax computes; "inclusive"
697# means the amount is the total and the tax is worked out from within it.
698[fields.tax_behavior]
699type = "string"
700default = "exclusive"
701
702[fields.tax_behavior.admin]
703options = ["exclusive|Tax added on top", "inclusive|Tax included in the amount"]
704
705[fields.active]
706type = "boolean"
707default = true
708
709# Stripe prices are immutable once created: changing an amount there means
710# creating a new price and archiving the old one, which is exactly what the
711# hook does. The id therefore points at whichever price object is current.
712[fields.stripe_price_id]
713type = "string"
714unique = true
715
716[fields.stripe_price_id.admin]
717readonly = true
718help = "Filled in by Stripe. Changing the amount creates a new price and archives the old one."
719"#;
720
721pub const BILLING_CUSTOMER_TOML: &str = r#"
729[resource]
730name = "billing_customer"
731scope = "organization"
732timestamps = true
733
734[admin]
735label = "Billing customer"
736plural = "Billing customers"
737
738[permissions]
739list = "role:admin" # billing is the admins' business
740read = "role:admin"
741create = "private"
742update = "private"
743delete = "private"
744
745[fields.stripe_customer_id]
746type = "string"
747required = true
748unique = true
749
750# Where invoices and receipts go. Defaults to the address of whoever first
751# started a checkout, and is changed from the Stripe portal.
752[fields.email]
753type = "string"
754max_length = 320
755
756[fields.name]
757type = "string"
758
759# The buyer's VAT/GST number, once they have given one. Kept because it is
760# what turns a taxed sale into a reverse-charge one, and support gets asked
761# about it more than anything else on this table.
762[fields.tax_id]
763type = "string"
764
765# Country Stripe places the customer in, for tax. Two-letter ISO 3166-1.
766[fields.tax_country]
767type = "string"
768max_length = 2
769
770# Everything else Stripe holds about the customer, as last delivered. Kept so
771# an operator can answer a billing question without opening two dashboards.
772[fields.details]
773type = "json"
774
775[fields.details.admin]
776visible = false
777"#;
778
779pub const BILLING_SUBSCRIPTION_TOML: &str = r#"
788[resource]
789name = "billing_subscription"
790scope = "organization"
791timestamps = true
792
793[admin]
794label = "Subscription"
795plural = "Subscriptions"
796
797[permissions]
798list = "member" # members can see what plan they are on
799read = "member"
800create = "private" # started by POST /billing/checkout
801update = "private" # written by the Stripe webhook
802delete = "private"
803
804[fields.price_id]
805type = "reference"
806references = "billing_price"
807on_delete = "set_null" # an archived price outlives its row
808
809[fields.customer_id]
810type = "reference"
811references = "billing_customer"
812on_delete = "cascade"
813
814# Stripe's own words: "trialing", "active", "past_due", "canceled",
815# "incomplete", "incomplete_expired", "unpaid", "paused". Anything checking
816# for a paying customer wants "active" or "trialing" — see the `payments`
817# guide for why "past_due" is a judgement call and not a status.
818[fields.status]
819type = "string"
820required = true
821
822[fields.quantity]
823type = "integer"
824default = 1
825
826[fields.current_period_end]
827type = "timestamp"
828
829# Set when the customer has asked to stop but has already paid through the end
830# of the period: still entitled, not renewing.
831[fields.cancel_at_period_end]
832type = "boolean"
833default = false
834
835[fields.trial_ends_at]
836type = "timestamp"
837
838[fields.canceled_at]
839type = "timestamp"
840
841[fields.stripe_subscription_id]
842type = "string"
843required = true
844unique = true
845
846[fields.stripe_subscription_id.admin]
847readonly = true
848"#;
849
850pub const BILLING_PAYMENT_TOML: &str = r#"
857[resource]
858name = "billing_payment"
859scope = "organization"
860timestamps = true
861
862[admin]
863label = "Payment"
864plural = "Payments"
865
866[permissions]
867list = "role:admin"
868read = "role:admin"
869create = "private"
870update = "private"
871delete = "private"
872
873[fields.customer_id]
874type = "reference"
875references = "billing_customer"
876on_delete = "cascade"
877
878[fields.price_id]
879type = "reference"
880references = "billing_price"
881on_delete = "set_null"
882
883[fields.subscription_id]
884type = "reference"
885references = "billing_subscription"
886on_delete = "set_null" # null for a one-off
887
888# What was actually taken, in the smallest unit, and what of it was tax.
889[fields.amount]
890type = "big_int"
891required = true
892
893[fields.tax_amount]
894type = "big_int"
895default = 0
896
897[fields.currency]
898type = "string"
899max_length = 3
900
901# "succeeded", "pending", "failed", "refunded".
902[fields.status]
903type = "string"
904required = true
905
906[fields.description]
907type = "string"
908
909# The buyer's own receipt, hosted by Stripe. Worth storing: it is the link
910# support is asked for, and it outlives the session that produced it.
911[fields.receipt_url]
912type = "string"
913max_length = 2048
914
915[fields.paid_at]
916type = "timestamp"
917
918[fields.stripe_payment_intent_id]
919type = "string"
920unique = true
921
922[fields.stripe_invoice_id]
923type = "string"
924"#;
925
926pub const BILLING_EVENT_TOML: &str = r#"
937[resource]
938name = "billing_event"
939scope = "global"
940timestamps = true
941
942[admin]
943label = "Billing event"
944plural = "Billing events"
945visible = false
946
947[permissions]
948list = "private"
949read = "private"
950create = "private"
951update = "private"
952delete = "private"
953
954[fields.stripe_event_id]
955type = "string"
956required = true
957unique = true
958
959[fields.kind]
960type = "string"
961required = true # "checkout.session.completed", "invoice.paid", …
962
963# When the work finished. A row with this null is an event that arrived and
964# then failed to process — Stripe will retry it, and this is where to look
965# when it stops retrying.
966[fields.processed_at]
967type = "timestamp"
968
969[fields.error]
970type = "text"
971
972[fields.payload]
973type = "json"
974hidden = true
975"#;
976
977pub fn builtins() -> Vec<(&'static str, &'static str)> {
980 vec![
981 ("organization", ORGANIZATION_TOML),
982 ("user", USER_TOML),
983 ("membership", MEMBERSHIP_TOML),
984 ("membership_role", MEMBERSHIP_ROLE_TOML),
985 ("api_key", API_KEY_TOML),
986 ("oauth_connection", OAUTH_TOML),
987 ("invitation", INVITATION_TOML),
988 ("auth_token", AUTH_TOKEN_TOML),
989 ]
990}
991
992pub fn billing_builtins() -> Vec<(&'static str, &'static str)> {
1006 vec![
1007 ("billing_product", BILLING_PRODUCT_TOML),
1008 ("billing_price", BILLING_PRICE_TOML),
1009 ("billing_customer", BILLING_CUSTOMER_TOML),
1010 ("billing_subscription", BILLING_SUBSCRIPTION_TOML),
1011 ("billing_payment", BILLING_PAYMENT_TOML),
1012 ("billing_event", BILLING_EVENT_TOML),
1013 ]
1014}
1015
1016pub fn oauth_builtins() -> Vec<(&'static str, &'static str)> {
1025 vec![("oauth_state", OAUTH_STATE_TOML)]
1026}
1027
1028pub fn parse_builtin(toml_src: &str) -> Resource {
1031 let r: Resource = toml::from_str(toml_src).expect("built-in resource TOML is valid");
1032 r.validate().expect("built-in resource is valid");
1033 r
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038 use super::*;
1039
1040 #[test]
1041 fn all_builtins_parse() {
1042 for (name, src) in builtins()
1043 .into_iter()
1044 .chain(billing_builtins())
1045 .chain(oauth_builtins())
1046 {
1047 let r = parse_builtin(src);
1048 assert_eq!(r.meta.name, name);
1049 }
1050 }
1051
1052 #[test]
1056 fn every_billing_resource_is_namespaced() {
1057 for (name, _) in billing_builtins() {
1058 assert!(name.starts_with("billing_"), "`{name}` is unprefixed");
1059 }
1060 }
1061
1062 #[test]
1066 fn billing_references_resolve_within_the_app() {
1067 let known: Vec<&str> = builtins()
1068 .into_iter()
1069 .chain(billing_builtins())
1070 .map(|(name, _)| name)
1071 .collect();
1072 for (name, src) in billing_builtins() {
1073 let resource = parse_builtin(src);
1074 for (field, spec) in &resource.fields {
1075 if let Some(target) = &spec.references {
1076 assert!(
1077 known.contains(&target.as_str()),
1078 "{name}.{field} points at an unknown `{target}`"
1079 );
1080 }
1081 }
1082 }
1083 }
1084}