rustlavel_flags/scope.rs
1//! Who a flag is being checked *for*.
2//!
3//! A flag on its own is not a question anybody can answer. "Is the new checkout
4//! on?" only has a meaning once you say for whom — this customer, that tenant,
5//! or everybody at once — and the scope is that second half of the question.
6
7use std::str::FromStr;
8
9/// The subject of a flag check.
10///
11/// Three kinds, because three are what applications actually roll out to:
12///
13/// * a **user**, by whatever identifier they log in with;
14/// * a **tenant**, when a feature is bought or enabled per customer rather than
15/// per person;
16/// * **nobody in particular** ([`Scope::none`]), for a switch that is on or off
17/// for the whole installation.
18///
19/// The identifier is a `String` and not a generic parameter on purpose. A flag
20/// store has to write it down, an operator has to type it into a console, and a
21/// percentage rollout has to hash it — all three want text, and a type
22/// parameter here would spread through every signature in the crate to buy
23/// nothing.
24///
25/// ```
26/// use rustlavel_flags::Scope;
27///
28/// assert_eq!(Scope::user("ada").id(), "ada");
29/// assert_eq!(Scope::user_id(41).id(), "41");
30/// assert_eq!(Scope::tenant("acme").key(), "tenant:acme");
31/// assert_eq!(Scope::none().id(), "");
32/// ```
33#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
34pub enum Scope {
35 /// Everybody. Built with [`Scope::none`].
36 #[default]
37 None,
38 /// One logged-in user, by their authentication identifier.
39 User(String),
40 /// One tenant, team, organisation — whatever the application calls it.
41 Tenant(String),
42}
43
44impl Scope {
45 /// The global scope: a flag that is simply on or off, for everyone.
46 ///
47 /// Named `none` rather than `global` because of how it reads at the call
48 /// site — `flags.active("maintenance", &Scope::none())` says "there is
49 /// nobody in particular here", which is the fact the caller has.
50 pub fn none() -> Self {
51 Scope::None
52 }
53
54 /// One user, by the identifier they log in with.
55 pub fn user(id: impl Into<String>) -> Self {
56 Scope::User(id.into())
57 }
58
59 /// One user, by a numeric primary key — the common case in an application
60 /// whose `users` table is keyed by `bigint`.
61 pub fn user_id(id: i64) -> Self {
62 Scope::User(id.to_string())
63 }
64
65 /// One tenant, by name or by id.
66 pub fn tenant(name: impl Into<String>) -> Self {
67 Scope::Tenant(name.into())
68 }
69
70 /// The identifier, or `""` for [`Scope::none`].
71 ///
72 /// The empty string is deliberate: a resolver that does not care which kind
73 /// of scope it was given can read `scope.id()` unconditionally, and a
74 /// global check falls through whatever test it applies rather than
75 /// panicking or matching some sentinel value.
76 pub fn id(&self) -> &str {
77 match self {
78 Scope::None => "",
79 Scope::User(id) | Scope::Tenant(id) => id,
80 }
81 }
82
83 /// The identifier parsed into a type: `scope.id_as::<i64>()`.
84 ///
85 /// `None` when the scope is global, or when the identifier is not that
86 /// type — a resolver keyed on a numeric user id gets one answer for both,
87 /// which is right, because it cannot serve either.
88 pub fn id_as<T: FromStr>(&self) -> Option<T> {
89 self.id().parse().ok()
90 }
91
92 /// `"global"`, `"user"` or `"tenant"` — for logs and for the store key.
93 pub fn kind(&self) -> &'static str {
94 match self {
95 Scope::None => "global",
96 Scope::User(_) => "user",
97 Scope::Tenant(_) => "tenant",
98 }
99 }
100
101 /// Whether this is the global scope.
102 pub fn is_none(&self) -> bool {
103 matches!(self, Scope::None)
104 }
105
106 /// The string a [`FlagStore`](crate::FlagStore) writes an override under.
107 ///
108 /// The kind is part of the key, so a user called `acme` and a tenant called
109 /// `acme` are two different subjects and cannot inherit each other's
110 /// overrides. An identifier containing a colon is left alone rather than
111 /// escaped — the prefix already separates the namespaces, and the only way
112 /// two keys can still collide is if one subject's identifier is literally
113 /// another kind's prefix plus another subject's identifier, which is a
114 /// collision between two identifiers the application itself issued.
115 pub fn key(&self) -> String {
116 match self {
117 Scope::None => "global".to_string(),
118 Scope::User(id) => format!("user:{id}"),
119 Scope::Tenant(id) => format!("tenant:{id}"),
120 }
121 }
122}
123
124impl std::fmt::Display for Scope {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 f.write_str(&self.key())
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn a_scope_carries_its_identifier_and_its_kind() {
136 assert_eq!(Scope::user("ada").kind(), "user");
137 assert_eq!(Scope::user("ada").id(), "ada");
138 assert_eq!(Scope::tenant("acme").kind(), "tenant");
139 assert_eq!(Scope::none().kind(), "global");
140 assert!(Scope::none().is_none());
141 assert!(!Scope::user("ada").is_none());
142 }
143
144 #[test]
145 fn a_numeric_identifier_survives_the_round_trip() {
146 assert_eq!(Scope::user_id(41).id(), "41");
147 assert_eq!(Scope::user_id(41).id_as::<i64>(), Some(41));
148 assert_eq!(Scope::user("ada").id_as::<i64>(), None);
149 assert_eq!(Scope::none().id_as::<i64>(), None);
150 }
151
152 #[test]
153 fn the_kind_is_part_of_the_key_so_two_namespaces_cannot_collide() {
154 // The bug this prevents: a tenant called `acme` inheriting the
155 // overrides an operator set for a *user* called `acme`.
156 assert_ne!(Scope::user("acme").key(), Scope::tenant("acme").key());
157 assert_eq!(Scope::user("acme").key(), "user:acme");
158 assert_eq!(Scope::none().key(), "global");
159 assert_eq!(Scope::none().to_string(), "global");
160 }
161
162 #[test]
163 fn the_default_scope_is_the_global_one() {
164 assert_eq!(Scope::default(), Scope::none());
165 }
166}