Skip to content

g1t/services/security/src/suite.rs

156 lines6,988 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1//! What the security suite's parts share: whether a repository has the
2//! paid features, telling people (events for webhooks and the inbox), and
3//! the audit log.
4
5use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
6use g1t_contracts::billing::{Feature, HasFeatureArgs};
7use g1t_contracts::events::{NewEvent, Publish};
8use g1t_contracts::identity::{ListMembersArgs, Member};
9use g1t_contracts::security_suite::{AlertType, EVENT_TYPES, PaidFeature, SecurityEvent, needs_activation};
10use g1t_contracts::{FailureCode, Outcome, Role, User, new_id};
11use g1t_kit::now_ms;
12use worker::Result;
13
14use crate::Security;
15use crate::store::RepoRow;
16
17/// The most workspace members an event names as able to see findings.
18const MAX_MEMBERS_NAMED: usize = 500;
19
20/// A failure for want of the activation.
21pub fn payment_required<T>(feature: PaidFeature, workspace: &str) -> Outcome<T> {
22 Outcome::fail(FailureCode::PaymentRequired, needs_activation(feature, workspace))
23}
24
25/// The page of a repository's security section: `/acme/rocket/security/…`.
26pub fn link(repo: &RepoRow, rest: &str) -> String {
27 let rest = rest.trim_start_matches('/');
28 if rest.is_empty() {
29 format!("/{}/{}/security", repo.namespace, repo.name)
30 } else {
31 format!("/{}/{}/security/{rest}", repo.namespace, repo.name)
32 }
33}
34
35impl Security {
36 /// Whether the workspace has the Security and quality activation (or
37 /// has it included). When billing cannot say, it is taken as not: a
38 /// paid feature waits rather than running unpaid.
39 pub(crate) async fn activated(&self, namespace: &str) -> bool {
40 let answer: Result<Outcome<bool>> = g1t_kit::call(
41 &self.billing,
42 "has_feature",
43 &HasFeatureArgs { workspace: namespace.to_owned(), feature: Feature::Security },
44 )
45 .await;
46 match answer {
47 Ok(Outcome::Ok(on)) => on,
48 Ok(Outcome::Fail(_)) => false,
49 Err(error) => {
50 worker::console_error!("security: has_feature for {namespace}: {error}");
51 false
52 }
53 }
54 }
55
56 /// Whether a repository has the paid features: it is public, or its
57 /// workspace has the activation.
58 pub(crate) async fn entitled(&self, repo: &RepoRow) -> Result<bool> {
59 let (_, private) = self.store.repo_settings(&repo.repo_id).await?;
60 Ok(!private || self.activated(&repo.namespace).await)
61 }
62
63 /// `None` when the repository may use `feature`, or the refusal.
64 pub(crate) async fn gate<T>(&self, repo: &RepoRow, feature: PaidFeature) -> Result<Option<Outcome<T>>> {
65 Ok((!self.entitled(repo).await?).then(|| payment_required(feature, &repo.namespace)))
66 }
67
68 /// The workspace's members, as g1t sees them.
69 pub(crate) async fn members(&self, namespace: &str) -> Vec<Member> {
70 let found: Result<Outcome<Vec<Member>>> = g1t_kit::call(
71 &self.identity,
72 "list_members",
73 &ListMembersArgs { slug: namespace.to_owned(), viewer: Some(User::system(namespace)) },
74 )
75 .await;
76 match found {
77 Ok(Outcome::Ok(members)) => members,
78 Ok(Outcome::Fail(failure)) => {
79 worker::console_error!("security: members of {namespace}: {}", failure.message);
80 Vec::new()
81 }
82 Err(error) => {
83 worker::console_error!("security: members of {namespace}: {error}");
84 Vec::new()
85 }
86 }
87 }
88
89 /// Publishes a security event, for webhooks and the inbox. New alerts
90 /// name the workspace's owners to tell, and its members as those who
91 /// may see findings. Failing to publish never fails the change.
92 pub(crate) async fn publish(&self, kind: &str, repo: &RepoRow, mut event: SecurityEvent, actor_id: Option<String>) {
93 let Some(kind) = EVENT_TYPES.iter().copied().find(|known| *known == kind) else {
94 worker::console_error!("security: no event called {kind}");
95 return;
96 };
97 let Some(events) = &self.events else { return };
98 let mut data = serde_json::to_value(&event).unwrap_or_default();
99 if kind.ends_with(".created") || kind == "secret_scanning.bypass_requested" {
100 let members = self.members(&repo.namespace).await;
101 if event.notify.is_empty() {
102 event.notify = members
103 .iter()
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA104 .filter(|member| member.role == Role::Owner || member.org_roles.contains(&g1t_contracts::OrgRole::SecurityManager))
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar105 .map(|member| member.username.clone())
106 .collect();
107 }
108 data = serde_json::to_value(&event).unwrap_or_default();
109 data["members"] = serde_json::json!(
110 members.iter().take(MAX_MEMBERS_NAMED).map(|member| member.username.clone()).collect::<Vec<_>>()
111 );
112 }
113 let published: Result<serde_json::Value> = g1t_kit::call(
114 events,
115 "publish",
116 &Publish {
117 events: vec![NewEvent { kind, source: "security", repo_id: Some(repo.repo_id.clone()), actor: actor_id, data }],
118 },
119 )
120 .await;
121 if let Err(error) = published {
122 worker::console_error!("security: {kind} for {} not published: {error}", repo.repo_id);
123 }
124 }
125
126 /// Publishes `<type>.<action>` for one alert.
127 pub(crate) async fn alert_event(&self, alert_type: AlertType, action: &str, repo: &RepoRow, event: SecurityEvent, actor_id: Option<String>) {
128 let kind = format!("{}.{action}", alert_type.event_prefix());
129 self.publish(&kind, repo, event, actor_id).await;
130 }
131
132 /// Records a security decision in the workspace's audit log.
133 pub(crate) async fn audit(&self, actor: &User, action: &str, repo: Option<&RepoRow>, namespace: &str, path: Option<&str>, message: &str) {
134 let Some(events) = &self.events else { return };
135 let entry = NewAuditEntry {
136 actor: AuditActor::of(actor),
137 action: action.to_owned(),
138 surface: Surface::Web,
139 target: AuditTarget {
140 workspace: namespace.to_owned(),
141 repo: repo.map(|repo| format!("{}/{}", repo.namespace, repo.name)),
142 path: path.map(str::to_owned),
143 ..AuditTarget::default()
144 },
145 outcome: AuditOutcome::Allowed,
146 rule: "security".to_owned(),
147 result: Some("ok".to_owned()),
148 message: Some(message.chars().take(500).collect()),
149 request_id: new_id("req", now_ms()),
150 };
151 let recorded: Result<u32> = g1t_kit::call(events, "audit_record", &RecordAuditArgs { entries: vec![entry] }).await;
152 if let Err(error) = recorded {
153 worker::console_error!("security: audit entry {action} not recorded: {error}");
154 }
155 }
156}

This file's history is long; its oldest lines are credited to the oldest commit read.