Merge pull request #354 from flagon-io/344-identity-session-batch
A signed-in page waits about half as long on who the session is
6 files+92−670/6 viewed
| 15 | 15 | ||
| 16 | 16 | # Generated by scripts/deploy.mjs for a runner deploy (the image by reference) | |
| 17 | 17 | wrangler.deploy.json | |
| 18 | − | ||
| 19 | − | # Personal notes for the next session | |
| 20 | − | /SCRUTINY.md | |
| 21 | − | /TODO.md | |
| 22 | − | /.prompts/ |
| 3 | 3 | //! | |
| 4 | 4 | //! The rules (which role may do what, and how a person's role is worked | |
| 5 | 5 | //! out) live in `g1t_contracts::access`; this is where the roles are kept. | |
| 6 | − | //! Every user identity resolves carries their grants ([`Identity::grants_of`], | |
| 6 | + | //! Every user identity resolves carries their grants ([`Identity::grants_statement`], | |
| 7 | 7 | //! under the workspace's policy) beside their memberships, so services | |
| 8 | 8 | //! decide with `access::can` and never call here to authorize. | |
| 9 | 9 | //! | |
| ⋯ | |||
| 25 | 25 | //! rename keeps them (deletion.rs, `transfer_repo_scopes`). | |
| 26 | 26 | //! | |
| 27 | 27 | //! **Teams** are another `principal_kind` in `repo_grants` (teams.rs): | |
| 28 | − | //! [`Identity::grants_of`] resolves a team's grants into the same | |
| 28 | + | //! [`Identity::grants_statement`] resolves a team's grants into the same | |
| 29 | 29 | //! `RepoGrant`s for each person in the team and in its child teams, and | |
| 30 | 30 | //! the access list shows where such a role comes from. | |
| 31 | 31 | ||
| ⋯ | |||
| 111 | 111 | } | |
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | + | /// The grants, each with whether its workspace requires two-factor | |
| 115 | + | /// authentication, from what `grants_statement` read. | |
| 116 | + | pub(crate) fn grants_from(result: &worker::D1Result) -> Result<Vec<(RepoGrant, bool)>> { | |
| 117 | + | Ok(result | |
| 118 | + | .results::<GrantRow>()? | |
| 119 | + | .into_iter() | |
| 120 | + | .filter_map(|row| { | |
| 121 | + | Some(( | |
| 122 | + | RepoGrant { | |
| 123 | + | repo_id: row.repo_id, | |
| 124 | + | workspace: row.workspace, | |
| 125 | + | role: RepoRole::parse(&row.role)?, | |
| 126 | + | team: row.team, | |
| 127 | + | }, | |
| 128 | + | row.require_two_factor != 0, | |
| 129 | + | )) | |
| 130 | + | }) | |
| 131 | + | .collect()) | |
| 132 | + | } | |
| 133 | + | ||
| 114 | 134 | #[derive(Deserialize)] | |
| 115 | 135 | struct GrantRow { | |
| 116 | 136 | repo_id: String, | |
| ⋯ | |||
| 257 | 277 | /// | |
| 258 | 278 | /// Each comes with whether its workspace requires two-factor | |
| 259 | 279 | /// authentication (security.rs). | |
| 260 | − | pub async fn grants_of(&self, user_id: &str) -> Result<Vec<(RepoGrant, bool)>> { | |
| 261 | − | let rows = self | |
| 262 | − | .db | |
| 280 | + | /// | |
| 281 | + | /// The statement, to batch with the other reads of an account (lib.rs | |
| 282 | + | /// `with_workspaces_via`); `grants_from` reads its result. | |
| 283 | + | pub(crate) fn grants_statement(&self, user_id: &str) -> Result<worker::D1PreparedStatement> { | |
| 284 | + | self.db | |
| 263 | 285 | .prepare(format!( | |
| 264 | 286 | "WITH RECURSIVE mine(id) AS ( | |
| 265 | 287 | SELECT team_id FROM team_members WHERE user_id = ?1 | |
| ⋯ | |||
| 276 | 298 | JOIN workspaces w ON w.id = g.workspace_id AND w.deleted_at IS NULL | |
| 277 | 299 | LIMIT {MAX_GRANTS}" | |
| 278 | 300 | )) | |
| 279 | − | .bind(&[user_id.into()])? | |
| 280 | − | .all() | |
| 281 | − | .await? | |
| 282 | − | .results::<GrantRow>()?; | |
| 283 | − | Ok(rows | |
| 284 | − | .into_iter() | |
| 285 | − | .filter_map(|row| { | |
| 286 | − | Some(( | |
| 287 | − | RepoGrant { | |
| 288 | − | repo_id: row.repo_id, | |
| 289 | − | workspace: row.workspace, | |
| 290 | − | role: RepoRole::parse(&row.role)?, | |
| 291 | − | team: row.team, | |
| 292 | − | }, | |
| 293 | − | row.require_two_factor != 0, | |
| 294 | − | )) | |
| 295 | − | }) | |
| 296 | − | .collect()) | |
| 301 | + | .bind(&[user_id.into()]) | |
| 297 | 302 | } | |
| 298 | 303 | ||
| 299 | 304 | /// The repository at `path` as `viewer` sees it: missing when they | |
| 159 | 159 | let Some(mut user) = user else { | |
| 160 | 160 | return Ok(None); | |
| 161 | 161 | }; | |
| 162 | + | // Read together, in one trip to the database: every signed-in page | |
| 163 | + | // waits on this before its loaders start. | |
| 164 | + | let read = self | |
| 165 | + | .db | |
| 166 | + | .batch(vec![ | |
| 167 | + | self.user_suspension_statement(&user.id)?, | |
| 168 | + | self.memberships_statement(&user.id)?, | |
| 169 | + | self.grants_statement(&user.id)?, | |
| 170 | + | ]) | |
| 171 | + | .await?; | |
| 172 | + | let [suspension, memberships, grants] = read.as_slice() else { | |
| 173 | + | return Err(worker::Error::RustError("the account's batch came back short".into())); | |
| 174 | + | }; | |
| 162 | 175 | // A suspended account is nobody, however it is presented | |
| 163 | 176 | // (suspension.rs): its sessions, tokens, keys and password. | |
| 164 | − | if self.user_suspension(&user.id).await?.is_some() { | |
| 177 | + | if suspension::user_suspension_from(suspension)?.is_some() { | |
| 165 | 178 | return Ok(None); | |
| 166 | 179 | } | |
| 167 | − | let policed = self.memberships_and_policies(&user.id).await?; | |
| 180 | + | let policed = workspaces::policed_from(memberships)?; | |
| 168 | 181 | let sso_required: Vec<(String, g1t_contracts::Role)> = | |
| 169 | 182 | policed.iter().filter(|m| m.require_sso).map(|m| (m.membership.slug.clone(), m.membership.role)).collect(); | |
| 170 | 183 | let memberships = policed.into_iter().map(|m| (m.membership, m.require_two_factor)).collect(); | |
| 171 | 184 | // Roles on single repositories, under the same policy (access.rs). | |
| 172 | − | let grants = self.grants_of(&user.id).await?; | |
| 185 | + | let grants = access::grants_from(grants)?; | |
| 173 | 186 | // Access to a workspace is used only within its policy; see security.rs. | |
| 174 | 187 | let within = self.within_policy(&user.id, memberships, grants).await?; | |
| 175 | 188 | user.workspaces = within.memberships; |
| 69 | 69 | } | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | + | /// The account's suspension, from what `user_suspension_statement` read. | |
| 73 | + | pub(crate) fn user_suspension_from(result: &worker::D1Result) -> Result<Option<Suspension>> { | |
| 74 | + | Ok(result.results::<SuspensionRow>()?.into_iter().next().and_then(SuspensionRow::shown)) | |
| 75 | + | } | |
| 76 | + | ||
| 72 | 77 | #[derive(Deserialize)] | |
| 73 | 78 | struct DomainRow { | |
| 74 | 79 | domain: String, | |
| ⋯ | |||
| 80 | 85 | impl Identity { | |
| 81 | 86 | /// The account's suspension, if it has one. | |
| 82 | 87 | pub(crate) async fn user_suspension(&self, user_id: &str) -> Result<Option<Suspension>> { | |
| 83 | − | Ok(self | |
| 84 | − | .db | |
| 88 | + | user_suspension_from(&self.user_suspension_statement(user_id)?.all().await?) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// What `user_suspension` reads, to batch with other reads. | |
| 92 | + | pub(crate) fn user_suspension_statement(&self, user_id: &str) -> Result<worker::D1PreparedStatement> { | |
| 93 | + | self.db | |
| 85 | 94 | .prepare("SELECT suspended_at, suspended_reason, suspended_by FROM users WHERE id = ?") | |
| 86 | − | .bind(&[user_id.into()])? | |
| 87 | − | .first::<SuspensionRow>(None) | |
| 88 | − | .await? | |
| 89 | − | .and_then(SuspensionRow::shown)) | |
| 95 | + | .bind(&[user_id.into()]) | |
| 90 | 96 | } | |
| 91 | 97 | ||
| 92 | 98 | /// The workspace's suspension, if it has one, by slug. | |
| 3 | 3 | //! Kept beside the workspaces they belong to: `teams` and `team_members`, | |
| 4 | 4 | //! and a team's roles on repositories as rows of `repo_grants` whose | |
| 5 | 5 | //! principal is the team. Nothing here decides access on a request: | |
| 6 | − | //! [`Identity::grants_of`] (access.rs) folds a person's teams' roles into | |
| 6 | + | //! [`Identity::grants_statement`] (access.rs) folds a person's teams' roles into | |
| 7 | 7 | //! the grants every resolved user carries. | |
| 8 | 8 | //! | |
| 9 | 9 | //! **Who may do what.** |
| 111 | 111 | pub require_sso: bool, | |
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | + | /// The memberships, from what `memberships_statement` read. | |
| 115 | + | pub(crate) fn policed_from(result: &worker::D1Result) -> Result<Vec<Policed>> { | |
| 116 | + | Ok(result | |
| 117 | + | .results::<MembershipRow>()? | |
| 118 | + | .into_iter() | |
| 119 | + | .map(|row| { | |
| 120 | + | let mut org_roles = Vec::new(); | |
| 121 | + | if row.billing_manager != 0 { | |
| 122 | + | org_roles.push(OrgRole::BillingManager); | |
| 123 | + | } | |
| 124 | + | if row.security_manager != 0 { | |
| 125 | + | org_roles.push(OrgRole::SecurityManager); | |
| 126 | + | } | |
| 127 | + | let membership = Membership { | |
| 128 | + | slug: row.slug, | |
| 129 | + | role: row.role, | |
| 130 | + | name: row.name, | |
| 131 | + | avatar: row.avatar, | |
| 132 | + | base_permission: row.base_permission.as_deref().and_then(BasePermission::parse), | |
| 133 | + | team_creation: row.team_creation.as_deref().and_then(TeamCreation::parse), | |
| 134 | + | org_roles, | |
| 135 | + | privileges: Some(MemberPrivileges::from_stored(row.member_privileges.as_deref())), | |
| 136 | + | }; | |
| 137 | + | Policed { membership, require_two_factor: row.require_two_factor != 0, require_sso: row.require_sso != 0 } | |
| 138 | + | }) | |
| 139 | + | .collect()) | |
| 140 | + | } | |
| 141 | + | ||
| 114 | 142 | impl Identity { | |
| 115 | 143 | /// The workspaces a user belongs to, attached to every user resolved | |
| 116 | 144 | /// from credentials, with what the site needs to show each one, what | |
| ⋯ | |||
| 123 | 151 | /// The same, each with what its workspace requires: two-factor | |
| 124 | 152 | /// authentication (security.rs), single sign-on (sso.rs). | |
| 125 | 153 | pub async fn memberships_and_policies(&self, user_id: &str) -> Result<Vec<Policed>> { | |
| 126 | − | let rows = self | |
| 127 | − | .db | |
| 154 | + | policed_from(&self.memberships_statement(user_id)?.all().await?) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// What `memberships_and_policies` reads, to batch with other reads. | |
| 158 | + | pub(crate) fn memberships_statement(&self, user_id: &str) -> Result<worker::D1PreparedStatement> { | |
| 159 | + | self.db | |
| 128 | 160 | .prepare( | |
| 129 | 161 | "SELECT workspaces.slug, workspace_members.role, workspaces.name, | |
| 130 | 162 | workspaces.avatar, workspaces.base_permission, workspaces.team_creation, | |
| ⋯ | |||
| 135 | 167 | WHERE workspace_members.user_id = ? AND workspaces.deleted_at IS NULL | |
| 136 | 168 | ORDER BY workspaces.slug", | |
| 137 | 169 | ) | |
| 138 | − | .bind(&[user_id.into()])? | |
| 139 | − | .all() | |
| 140 | − | .await? | |
| 141 | − | .results::<MembershipRow>()?; | |
| 142 | − | Ok(rows | |
| 143 | − | .into_iter() | |
| 144 | − | .map(|row| { | |
| 145 | − | let mut org_roles = Vec::new(); | |
| 146 | − | if row.billing_manager != 0 { | |
| 147 | − | org_roles.push(OrgRole::BillingManager); | |
| 148 | − | } | |
| 149 | − | if row.security_manager != 0 { | |
| 150 | − | org_roles.push(OrgRole::SecurityManager); | |
| 151 | − | } | |
| 152 | − | let membership = Membership { | |
| 153 | − | slug: row.slug, | |
| 154 | − | role: row.role, | |
| 155 | − | name: row.name, | |
| 156 | − | avatar: row.avatar, | |
| 157 | − | base_permission: row.base_permission.as_deref().and_then(BasePermission::parse), | |
| 158 | − | team_creation: row.team_creation.as_deref().and_then(TeamCreation::parse), | |
| 159 | − | org_roles, | |
| 160 | − | privileges: Some(MemberPrivileges::from_stored(row.member_privileges.as_deref())), | |
| 161 | − | }; | |
| 162 | − | Policed { membership, require_two_factor: row.require_two_factor != 0, require_sso: row.require_sso != 0 } | |
| 163 | − | }) | |
| 164 | − | .collect()) | |
| 170 | + | .bind(&[user_id.into()]) | |
| 165 | 171 | } | |
| 166 | 172 | ||
| 167 | 173 | pub async fn create_workspace(&self, a: CreateWorkspaceArgs) -> Result<Outcome<Workspace>> { | |
Comments
Sign in to comment.