Skip to content

Commit e40aa0c

Browse files
committed
implement events system
will be used in the future for both activity feed and audit logs
1 parent 318cc43 commit e40aa0c

32 files changed

Lines changed: 1262 additions & 88 deletions

gitarena/src/events.rs

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
use crate::database::{Database, Pool};
2+
use crate::organization::Organization;
3+
use crate::repository::Repository;
4+
use crate::session;
5+
use crate::user::User;
6+
use actix_web::HttpRequest;
7+
use anyhow::{Result, anyhow};
8+
use ipnetwork::IpNetwork;
9+
use opentelemetry::trace::{TraceContextExt, TraceId};
10+
use serde::{Deserialize, Serialize};
11+
use serde_json::{Map, Value};
12+
use sqlx::{FromRow, Transaction};
13+
use tokio::sync::OnceCell;
14+
use tracing::{Span, instrument};
15+
use tracing_opentelemetry::OpenTelemetrySpanExt;
16+
use utoipa::ToSchema;
17+
use uuid::Uuid;
18+
19+
pub(crate) static SYSTEM_USER: OnceCell<Uuid> = OnceCell::const_new();
20+
21+
pub(crate) async fn init(db_pool: &Pool) -> Result<()> {
22+
let mut tx = db_pool.begin().await?;
23+
24+
let user = User::find_using_name("gitarena", &mut tx)
25+
.await
26+
.ok_or_else(|| anyhow!("system user named `gitarena` not found"))?;
27+
28+
let _ = SYSTEM_USER.set(user.id);
29+
30+
tx.commit().await?;
31+
Ok(())
32+
}
33+
34+
#[derive(Debug, Serialize, Deserialize, FromRow, ToSchema)]
35+
pub(crate) struct Event {
36+
/// ID
37+
pub(crate) id: Uuid,
38+
39+
#[serde(skip)]
40+
pub(crate) trace_id: Option<Uuid>,
41+
42+
/// Actor user ID
43+
/// May be nil UUID if no user id exists for the triggering users (only on `auth.login_failed` and maybe on `email.verified` and `git.*`)
44+
pub(crate) actor_id: Uuid,
45+
/// IP Address
46+
pub(crate) ip_address: Option<IpNetwork>,
47+
/// User agent
48+
pub(crate) user_agent: Option<String>,
49+
50+
/// Subject user id
51+
pub(crate) subject_id_user: Option<Uuid>,
52+
/// Subject organization id
53+
pub(crate) subject_id_org: Option<Uuid>,
54+
/// Subject repository id
55+
pub(crate) subject_id_repo: Option<Uuid>,
56+
57+
/// Class
58+
pub(crate) class: EventClass,
59+
/// Type
60+
#[sqlx(rename = "type")]
61+
#[serde(rename = "type")]
62+
pub(crate) type_: String,
63+
/// Payload
64+
pub(crate) payload: Value,
65+
}
66+
67+
impl Event {
68+
#[must_use]
69+
pub(crate) fn new(event: &'static str, actor: Uuid, request: &HttpRequest, subject: Subject, payload: Option<Value>) -> Self {
70+
let (ip_network, user_agent) = session::extract_ip_and_ua_owned(request);
71+
72+
let mut event = Self::new_without_request(event, actor, subject, payload);
73+
event.ip_address = Some(ip_network);
74+
event.user_agent = Some(user_agent);
75+
76+
event
77+
}
78+
79+
#[must_use]
80+
pub(crate) fn new_without_actor(event: &'static str, request: &HttpRequest, subject: Subject, payload: Option<Value>) -> Self {
81+
let uuid = SYSTEM_USER.get().expect("system user to be filled at startup");
82+
Self::new(event, *uuid, request, subject, payload)
83+
}
84+
85+
#[must_use]
86+
pub(crate) fn new_without_request(event: &'static str, actor: Uuid, subject: Subject, payload: Option<Value>) -> Self {
87+
let span = Span::current();
88+
let trace_id = if span.is_disabled() || span.is_none() {
89+
None
90+
} else {
91+
let otel_trace_id = span.context().span().span_context().trace_id();
92+
if otel_trace_id != TraceId::INVALID {
93+
Some(Uuid::from_u128(u128::from_be_bytes(otel_trace_id.to_bytes())))
94+
} else {
95+
None
96+
}
97+
};
98+
99+
Self {
100+
id: Uuid::now_v7(),
101+
trace_id,
102+
actor_id: actor,
103+
ip_address: None,
104+
user_agent: None,
105+
subject_id_user: subject.user(),
106+
subject_id_org: subject.org(),
107+
subject_id_repo: subject.repo(),
108+
class: EventClass::from_event(event),
109+
type_: event.to_string(),
110+
payload: payload.unwrap_or_else(|| Value::Object(Map::new())),
111+
}
112+
}
113+
114+
#[must_use]
115+
pub(crate) async fn new_without_actor_and_request(event: &'static str, subject: Subject, payload: Option<Value>) -> Self {
116+
let uuid = SYSTEM_USER.get().expect("system user to be filled at startup");
117+
Self::new_without_request(event, *uuid, subject, payload)
118+
}
119+
120+
#[instrument(err, skip(tx))]
121+
pub(crate) async fn save(self, tx: &mut Transaction<'_, Database>) -> Result<()> {
122+
sqlx::query(
123+
"insert into events (id, trace_id, actor_id, ip_address, user_agent, subject_id_user, subject_id_org, subject_id_repo, class, type, payload) \
124+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
125+
)
126+
.bind(self.id)
127+
.bind(self.trace_id)
128+
.bind(self.actor_id)
129+
.bind(self.ip_address)
130+
.bind(self.user_agent)
131+
.bind(self.subject_id_user)
132+
.bind(self.subject_id_org)
133+
.bind(self.subject_id_repo)
134+
.bind(self.class)
135+
.bind(self.type_)
136+
.bind(self.payload)
137+
.execute(&mut **tx)
138+
.await?;
139+
140+
Ok(())
141+
}
142+
143+
pub(crate) async fn save_pool(self, db_pool: &Pool) -> Result<()> {
144+
let mut tx = db_pool.begin().await?;
145+
146+
self.save(&mut tx).await?;
147+
148+
tx.commit().await?;
149+
Ok(())
150+
}
151+
}
152+
153+
#[derive(Debug, Serialize, Deserialize, sqlx::Type, ToSchema)]
154+
#[sqlx(type_name = "event_class", rename_all = "lowercase")]
155+
pub(crate) enum EventClass {
156+
/// Events a user should review upon account compromise
157+
Security,
158+
/// Events used to build timelines for the profile or dashboard etc.
159+
Activity,
160+
/// Events triggered without any human behind it, e.g. cron jobs
161+
System,
162+
}
163+
164+
impl EventClass {
165+
fn from_event(event: &'static str) -> Self {
166+
match event {
167+
"user.disabled" => EventClass::Security,
168+
"repo.visibility_changed" => EventClass::Security,
169+
"repo.transferred" => EventClass::Security,
170+
_ if event.starts_with("auth.") => EventClass::Security,
171+
_ if event.starts_with("session.") => EventClass::Security,
172+
_ if event.starts_with("ssh_key.") => EventClass::Security,
173+
_ if event.starts_with("passkey.") => EventClass::Security,
174+
_ if event.starts_with("email.") => EventClass::Security,
175+
_ if event.starts_with("privilege.") => EventClass::Security,
176+
177+
"user.created" => EventClass::Activity,
178+
"user.updated" => EventClass::Activity,
179+
"user.deleted" => EventClass::Activity,
180+
"repo.created" => EventClass::Activity,
181+
"repo.updated" => EventClass::Activity,
182+
"repo.deleted" => EventClass::Activity,
183+
"repo.archived" => EventClass::Activity,
184+
"repo.unarchived" => EventClass::Activity,
185+
"repo.forked" => EventClass::Activity,
186+
"repo.mirrored" => EventClass::Activity,
187+
_ if event.starts_with("org.") => EventClass::Activity,
188+
_ if event.starts_with("star.") => EventClass::Activity,
189+
_ if event.starts_with("issue.") => EventClass::Activity,
190+
_ if event.starts_with("git.") => EventClass::Activity,
191+
192+
_ => unimplemented!("unknown event {event}. please implement in `EventClass::from_event`"),
193+
}
194+
}
195+
}
196+
197+
#[derive(Serialize, Deserialize, ToSchema)]
198+
pub(crate) enum Subject {
199+
User(Uuid),
200+
Org(Uuid),
201+
Repo(Uuid),
202+
}
203+
204+
impl Subject {
205+
fn user(&self) -> Option<Uuid> {
206+
match self {
207+
Subject::User(uuid) => Some(*uuid),
208+
_ => None,
209+
}
210+
}
211+
212+
fn org(&self) -> Option<Uuid> {
213+
match self {
214+
Subject::Org(uuid) => Some(*uuid),
215+
_ => None,
216+
}
217+
}
218+
219+
fn repo(&self) -> Option<Uuid> {
220+
match self {
221+
Subject::Repo(uuid) => Some(*uuid),
222+
_ => None,
223+
}
224+
}
225+
}
226+
227+
impl From<&User> for Subject {
228+
fn from(value: &User) -> Self {
229+
Subject::User(value.id)
230+
}
231+
}
232+
233+
impl From<&Organization> for Subject {
234+
fn from(value: &Organization) -> Self {
235+
Subject::Org(value.id)
236+
}
237+
}
238+
239+
impl From<&Repository> for Subject {
240+
fn from(value: &Repository) -> Self {
241+
Subject::Repo(value.id)
242+
}
243+
}

0 commit comments

Comments
 (0)