make acls actually do something

This commit is contained in:
tezlm 2023-07-29 13:17:11 -07:00
parent f06811c7d6
commit 6bbd874162
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
14 changed files with 189 additions and 65 deletions

View file

@ -540,9 +540,9 @@ async fn main() -> Result<(), Error> {
.map(|i| {
i.split_once('/').map(|i| {
(
i.0.parse().expect("invalid item ref"),
i.1.parse().expect("invalid item ref"),
RelInfo {
rel_type: i.1.to_string(),
rel_type: i.0.to_string(),
key: None,
},
)

View file

@ -2,13 +2,13 @@
- [ ] base blob system
- [x] upload/download blobs
- [ ] enumerate blobs
- [x] enumerate blobs
- [ ] garbage collect redacted blobs
- [ ] process events
- [x] x.file
- [x] x.redact
- [ ] x.user
- [ ] x.acl
- [x] x.acl
- [x] x.tag.local
- [ ] x.tag
- [ ] x.annotate
@ -19,6 +19,11 @@
- [x] by tags
- [x] by relations
- [x] by sender
- [ ] decentralization
- [-] other servers can get events
- [ ] other servers can query relations
- [ ] other servers can watch for relations
- [ ] works through dht
- [-] misc
- [x] files as blobs
- [x] file thumbnails

View file

@ -13,7 +13,7 @@ type Role = String;
pub struct Acl {
pub roles: HashMap<Role, Permissions>,
pub users: HashMap<ActorId, HashSet<Role>>,
pub admins: HashSet<ActorId>,
pub admins: Vec<ActorId>, // HashSet would be better, but is unordered (bad with canonical json)
}
// #[test]
@ -33,34 +33,32 @@ impl Acl {
self.users.contains_key(user) || self.admins.contains(user)
}
pub fn can_send(&self, user: &ActorId, event: &Event, relations: &Relations) -> bool {
pub fn can_send(&self, user: &ActorId, event_type: &str, relations: &Relations) -> bool {
// admins can send any kind of event
if self.admins.contains(user) {
return true;
}
// if a user doesn't exist, they don't have any permission
let Some(roles) = self.users.get(user) else {
return false;
};
let permissions: Option<Vec<&Permissions>> =
roles.iter().map(|r| self.roles.get(r)).collect();
let Some(permissions) = permissions else {
return false;
};
let permissions: Permissions = permissions.into_iter().flatten().cloned().collect();
let permissions: Permissions = roles
.iter()
.flat_map(|r| self.roles.get(r))
.flatten()
.cloned()
.collect();
for (from_event_type, rel_type, to_event_type) in permissions {
if from_event_type != event.content.get_type() && from_event_type != "*" {
if from_event_type != event_type && from_event_type != "*" {
continue;
}
for (item_ref, rel_info) in &event.relations {
let Some((target, _)) = relations.get(item_ref) else {
continue;
};
let valid_type = target.content.get_type() == to_event_type || to_event_type == "*";
for (rel_event, rel_info) in relations.values() {
let valid_type =
rel_event.content.get_type() == to_event_type || to_event_type == "*";
let valid_rel = rel_info.rel_type == rel_type || rel_type == "*";
if valid_type && valid_rel {
return true;

View file

@ -102,6 +102,9 @@ pub async fn prepare_special(
return Ok(DelayedAction::Tag(targets, content.tags.clone()));
}
EventContent::Acl(_) => {
return Ok(DelayedAction::None);
}
EventContent::Other { event_type, .. } => {
if event_type.starts_with("x.") {
return Err(Error::Validation("unknown core event"));
@ -117,6 +120,7 @@ pub async fn prepare_special(
}
pub async fn commit_special(me: &Items, action: &DelayedAction) -> Result<(), Error> {
debug!("commit special {:?}", action);
match action {
DelayedAction::Redact(refs) => {
// TODO: garbage collect unreferenced blobs
@ -173,10 +177,15 @@ pub async fn update_search_index(
}
EventContent::LocalTag(_) | EventContent::Tag(_) => {
for (rel, _) in relations.values() {
reindex(me, rel).await?;
if let EventContent::File(_) = &rel.content {
reindex(me, rel).await?;
}
}
}
_ => reindex(me, event).await?,
EventContent::File(_) => {
reindex(me, event).await?;
}
_ => (),
};
Ok(())
}

View file

@ -1,6 +1,7 @@
use crate::{
blobs,
items::events::update_search_index,
perms::can_send_event,
routes::things::{thumbnail::ThumbnailSize, Error},
state::{
db::{sqlite::Sqlite, Database},
@ -8,6 +9,7 @@ use crate::{
},
Relations,
};
use axum::http::StatusCode;
use bytes::Bytes;
use events::DelayedAction;
use lru::LruCache;
@ -65,11 +67,19 @@ impl Items {
// unsure whether to return rowid (and relations) here...
pub async fn begin_event_create(&self, wip: WipEvent) -> Result<WipCreate, Error> {
debug!("begin new create");
dbg!(wip.to_json());
if !wip.has_valid_signature() {
return Err(Error::Validation("missing or invalid signature"));
}
if !can_send_event(&self.db, &wip).await? {
return Err(Error::Http(
StatusCode::FORBIDDEN,
"you can't send this event".into(),
));
}
let item_ref = self.blobs.put(blobs::Item::WipEvent(wip.clone())).await?;
let event = Event {
id: item_ref.clone(),

View file

@ -4,23 +4,29 @@ use std::collections::VecDeque;
use crate::{
items::events::get_relations,
state::db::{sqlite::Sqlite, Database},
routes::things::Error,
state::db::{sqlite::Sqlite, Database, DbItem},
Relations,
};
use tracing::trace;
use ufh::{
acl::Acl,
actor::ActorId,
event::{Event, EventContent},
event::{Event, EventContent, WipEvent},
item::ItemRef,
};
// TODO: find out how to cache this
#[async_recursion::async_recursion]
#[tracing::instrument(skip_all, fields(event.id, user))]
pub async fn can_view_event(db: &Sqlite, event: &Event, user: &ActorId) -> bool {
let relations = get_relations(db, event).await.unwrap();
dbg!(&event.sender, user);
// event is visible if user has sent the event
if &event.sender == user {
trace!("event matches because sender == user");
return true;
}
@ -32,9 +38,22 @@ pub async fn can_view_event(db: &Sqlite, event: &Event, user: &ActorId) -> bool
}
// or if an acl allows a user to view an event
get_acl(db, &event.id)
.await
.is_some_and(|acl| acl.can_view(user))
let acl = get_acl(db, &event.id).await;
match acl {
Some(acl) => {
if acl.can_view(user) {
trace!("event matches because acl allows it");
true
} else {
trace!("event doesn't match because acl doesn't allow viewing");
false
}
}
None => {
trace!("event doesn't match because no acl exists");
false
}
}
}
// async fn is_event_visible_nonrecursive(db: &Sqlite, event: &Event, user: &ActorId) -> bool {
@ -61,46 +80,90 @@ pub async fn can_view_event(db: &Sqlite, event: &Event, user: &ActorId) -> bool
struct Context<'a> {
relations: &'a Relations,
event: &'a Event,
event: &'a WipEvent,
user: &'a ActorId,
}
pub async fn can_send_event(db: &Sqlite, event: &Event, user: &ActorId) -> bool {
let relations = get_relations(db, event).await.unwrap();
pub async fn can_send_event(db: &Sqlite, wip: &WipEvent) -> Result<bool, Error> {
let Some(relations) = &wip.relations else {
trace!("event is sendable because there are no relations");
return Ok(true);
};
let rel_ids: Vec<_> = relations.keys().cloned().collect();
let rel_events = db.bulk_fetch(&rel_ids, false).await?;
let relations = rel_events
.into_iter()
.map(|(item_ref, item)| {
let rel_info = &relations[&item_ref];
match item {
DbItem::Event(event) => Ok((item_ref, (*event, rel_info.clone()))),
DbItem::Blob => Err(Error::Validation(
"some relations are to blobs instead of events",
)),
}
})
.collect::<Result<Relations, Error>>()?;
let ctx = Context {
relations: &relations,
event,
user,
event: wip,
user: &wip.sender,
};
for (rel, _) in relations.values() {
if !is_relation_valid(db, rel, &ctx).await {
return false;
if !is_relation_valid(db, rel, &ctx).await? {
trace!("event is invalid because all a relation is invalid");
return Ok(false);
}
}
true
trace!(
"event is valid because all {} relations are valid",
relations.len()
);
Ok(true)
}
async fn validate_relations(db: &Sqlite, event: &Event, ctx: &Context<'_>) -> bool {
let relations = get_relations(db, event).await.unwrap();
async fn validate_relations(db: &Sqlite, event: &Event, ctx: &Context<'_>) -> Result<bool, Error> {
let relations = get_relations(db, event).await?;
if relations.is_empty() {
trace!("relations invalid because it's a root event");
return Ok(false);
}
for (rel, _) in relations.values() {
if !is_relation_valid(db, rel, ctx).await {
return false;
if !is_relation_valid(db, rel, ctx).await? {
trace!("relation is invalid because a (sub)relation is invalid");
return Ok(false);
}
}
true
trace!(
"relation is valid because all {} (sub)relations are valid",
relations.len()
);
Ok(true)
}
#[async_recursion::async_recursion]
async fn is_relation_valid(db: &Sqlite, relation: &Event, ctx: &Context<'_>) -> bool {
async fn is_relation_valid(
db: &Sqlite,
relation: &Event,
ctx: &Context<'_>,
) -> Result<bool, Error> {
// a relation is allowed if the user has sent that event
if &relation.sender == ctx.user {
return true;
trace!("relation is valid because sender == user");
return Ok(true);
}
if let Some(acl) = get_acl(db, &relation.id).await {
// or if an acl set on it allows it
acl.can_send(ctx.user, ctx.event, ctx.relations)
let valid = acl.can_send(ctx.user, ctx.event.content.get_type(), ctx.relations);
if valid {
trace!("relation is valid because acl matches");
} else {
trace!("relation is invalid because acl does not match");
}
Ok(valid)
} else {
// or if all of it's relations are also valid
validate_relations(db, relation, ctx).await
@ -109,7 +172,7 @@ async fn is_relation_valid(db: &Sqlite, relation: &Event, ctx: &Context<'_>) ->
async fn get_acl(db: &Sqlite, item_ref: &ItemRef) -> Option<Acl> {
let result = db
.query_relations(&[("acl", "acl")], &[item_ref.clone()])
.query_relations(&[("x.acl", "acl")], &[item_ref.clone()])
.await
.ok()?;
let (_, event) = result.into_iter().last()?;

View file

@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tracing::debug;
use tracing::{debug, trace};
use ufh::item::ItemRef;
use ufh::{event::Event, query::MatchType};
@ -65,21 +65,35 @@ pub async fn route(
.db
.query_events(&query, params.limit, params.after.map(|p| p.to_string()))
.await?;
let has_events = !result.events.is_empty();
use futures_util::stream::{self, StreamExt};
let result = crate::state::db::QueryResult {
events: stream::iter(result.events)
.filter_map(|event| async {
can_view_event(&state.db, &event, &user)
.await
.then_some(event)
let visible = can_view_event(&state.db, &event, &user).await;
trace!("check if event matches (visible = {})", visible);
visible.then_some(event)
})
.collect()
.await,
..result
};
if result.events.is_empty() && has_events {
// TODO (performance): paginate server side
return Ok(QueryResult {
events: result.events,
relations: None,
next: result.next,
});
}
// FIXME: send newly visible events and remove newly hidden events
// this could be hard to do! events in the past could be made visible,
// which messes with rowid. i'd need to either restructure long
// polling to work or switch to websockets
let result = match (params.timeout, result.events.is_empty()) {
(Some(timeout), true) => {
debug!("no events, waiting for new ones...");

View file

@ -13,6 +13,7 @@ use ufh::item::ItemRef;
use super::{Error, Response};
use crate::{
items::Item,
perms::can_view_event,
routes::util::{perms, Authenticate},
ServerState,
};
@ -43,7 +44,22 @@ pub async fn route(
blob,
))
}
Item::Event(event) if auth.level > 0 || event.content.get_type() == "x.file" => {
Item::Event(event) => {
let is_visible = if let Some(user) = &auth.user {
can_view_event(&state.db, &event, user).await
} else {
event.content.get_type() == "x.file" && !event.is_redacted()
};
if !is_visible {
// TODO (security): should this be "not found" instead of forbidden?
// it's not like hashes can be reversed or they can view it anyway, but
// it *might* be good to hide information at the cost of some qol?
return Err(Error::Http(
StatusCode::FORBIDDEN,
"you can't view this event".into(),
));
}
debug!("got event");
let event_str = serde_json::to_string(&event)?;
let status = if event.is_redacted() {
@ -57,10 +73,5 @@ pub async fn route(
Bytes::from(event_str.into_bytes()),
))
}
// TODO (security): should this be "not found" instead of forbidden?
_ => Err(Error::Http(
StatusCode::FORBIDDEN,
"you can't view this event".into(),
)),
}
}

View file

@ -266,17 +266,16 @@ impl Database for Sqlite {
JOIN events AS events_from ON events_from.ref = relations.ref_from
JOIN events AS events_to ON events_to.ref = relations.ref_to
LEFT JOIN derived ON derived.ref = events_from.ref
WHERE (relations.rel_type, events_from.type) IN (
WHERE (events_from.type, relations.rel_type) IN (
",
);
builder.push_tuples(relations, |mut q, tup| {
q.push_bind(tup.0.to_owned()).push_bind(tup.1.to_owned());
q.push_bind(tup.0).push_bind(tup.1);
});
builder.push(") AND events_to.ref IN (");
let mut sep = builder.separated(",");
for item_ref in for_events {
let item_ref_str = item_ref.to_string();
sep.push_bind(item_ref_str);
sep.push_bind(item_ref.to_string());
}
builder.push(")");
debug!("generated sql: {}", builder.sql());

View file

@ -37,6 +37,12 @@ extern "pseudocode" fn is_event_sendable(event: &Event, user: &ActorId) -> bool
fn validate_relations(event: &Event, ctx: &Context) -> bool {
let relations = get_relations(event);
// root events always deny
if relations.is_empty() {
return false;
}
relations
.values()
.all(|rel| is_relation_valid(rel, ctx))

View file

@ -111,10 +111,12 @@ export const api = {
async function getPagination(): Promise<string> {
if (pagination) return pagination;
// TODO: make query.senders default to pubkey
const queryReal = {
...query,
senders: [`%${base64.encode(await ed25519.getPublicKeyAsync(self.key))}`],
};
// const queryReal = {
// ...query,
// senders: [`%${base64.encode(await ed25519.getPublicKeyAsync(self.key))}`],
// };
// FIXME: this lets anyone make any file appear to a person, which isn't good
const queryReal = query;
const res = await fetch(self.baseUrl + "things/query", {
method: "POST",
@ -167,6 +169,7 @@ export const api = {
}
},
async makeEvent(type: string, content: any, relations?: Relations): Promise<Event> {
// TODO: cache public key
const sender = "%" + base64.encode(await ed25519.getPublicKeyAsync(this.key));
const event = { type, content, sender, origin_ts: Date.now() } as any;
if (relations) event.relations = relations;

View file

@ -47,7 +47,7 @@
const query = api.query({
types: ["x.file"],
tags: tags.length ? tags : null,
relations: [["redact", "x.redact"]],
relations: [["x.redact", "redact"]],
}, true);
return {
events: collectEvents(query),

View file

@ -30,7 +30,7 @@
function loadComments(event) {
// TODO: stream relations?
const query = api.query({ refs: [event.id], relations: [["comment", "l.forum.comment"]] });
const query = api.query({ refs: [event.id], relations: [["l.forum.comment", "comment"]] });
let update;
const root = { event, children: [] };
const treeIndex = new Map();

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { api, ed25519 } from "../lib/api";
import { api, base64, ed25519 } from "../lib/api";
</script>
Server: <input
type="text"
@ -23,3 +23,9 @@ Secret key: <input
localStorage.setItem("key", newKey);
}}
>
<br />
{#await ed25519.getPublicKeyAsync(api.key)}
User id: <input type="text" readonly value="loading...">
{:then pubkey}
User id: <input type="text" readonly value={"%" + base64.encode(pubkey)}>
{/await}