ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

macro 的 model_owner 类型化归属体系:Owner、CreationPrincipal 与 owner_team 团队可见性策略

macro 的 model_owner 类型化归属体系:Owner、CreationPrincipal 与 owner_team 团队可见性策略
  • 后端
  • 前端
  • 协同办公
  • 即时通讯
  • 企业应用
  • CRM
  • AI Agent
  • 人工智能

【免费下载链接】macro

Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.

项目地址:https://gitcode.com/GitHub_Trending/macro3/macro
点击查看免费下载

在 macro 这样把邮箱、聊天、文档、任务、Agent 与 CRM 打通的工作区里,"这条数据属于谁"是一个被几乎所有领域 crate 反复追问的问题。model_owner就是为这个问题提供统一答案的共享基础设施:它把归属者建模为封闭的三值类型(用户、机器人、团队),把"谁创建"与"谁拥有"分离,并用一套 Rust 函数与 PostgreSQL 函数双实现来定义团队链接的可见范围。读完之后,你将掌握该 crate 的值类型语义、principal 字符串的严格解析规则、创建者解析链路,以及 Rust 与 SQL 两侧必须保持同步的那条可见性策略。

一、crate 定位:做什么,不做什么

crates/model_owner/AGENTS.md对该 crate 的定位给出了三条硬边界,这也是理解后续所有设计的前提:

维度内容
是什么被几乎所有带类型化归属的领域 crate 共享的基础设施;提供Owner/OwnerType这一类型化归属引用,区别于"实际操作者"
拥有什么Owner、OwnerType、CreationPrincipal三个值类型,以及团队可见范围策略函数
不负责什么归属者是否真的能访问某实体 → 交由models_permissions、entity_access判定
依赖仅依赖bot_id、macro_user_id两个底层标识 crate
被谁使用agent_session、agent_trigger、bots、channels、databases_sql、documents、entity_access、entity_registry、model、model_notifications、models_permissions、models_soup、projects、scheduled_action、soup、teams等

这条"不负责鉴权"的边界在代码里被反复强调。例如 team.rs 的模块文档开头就写明"解析受众从不授予归属权",而 entity_registry 的 owner_grant_policy.rs 也说明:把归属展开为授权来源,并不等于创建授权。

整个 crate 的依赖面极小,见 Cargo.toml:除bot_id、macro_user_id外只有serde、thiserror、uuid,sqlx是可选的 feature。

二、Owner:三种主体与三种 principal 字符串

2.1 枚举定义

src/lib.rs 定义了归属者枚举,每个变体都携带一个"已解析"的强类型标识,而不是裸字符串:

/// Who owns an entity. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(into = "String", try_from = "String")] pub enum Owner { /// A Macro user (`macro|<email>`). User(MacroUserIdStr<'static>), /// A bot (`bot|<uuid>`). Bot(BotId), /// A team (hyphenated UUID, no prefix). Team(Uuid), }

三种主体的存储形式与示例:

变体存储形式前缀示例
Owner::Usermacro|<email>macro\|macro\|hutch@macro.com
Owner::Botbot|<uuid>bot\|bot\|00000000-0000-0000-0000-00000000a1a1
Owner::Team带连字符的 36 位 UUID无01234567-89ab-cdef-0123-456789abcdef

注意团队不带前缀,用户与机器人必须带前缀。前缀常量定义在 lib.rs,团队 UUID 长度常量TEAM_UUID_HYPHENATED_LEN = 36也在同处。

2.2 先有类型,再解析

Owner提供两个入口:

  • Owner::parse(owner_type, value):在已知类型下解析,严格匹配该类型的存储形式(lib.rs#L105-L119);
  • Owner::from_principal_str(value):按前缀猜类型,macro|走用户、bot|走机器人,其余一律按团队处理(lib.rs#L121-L130)。
pub fn from_principal_str(value: &str) -> Result<Self, OwnerParseError> { if value.starts_with(USER_PRINCIPAL_PREFIX) { Self::parse(OwnerType::User, value) } else if value.starts_with(BOT_PRINCIPAL_PREFIX) { Self::parse(OwnerType::Bot, value) } else { Self::parse(OwnerType::Team, value) } }

"其余一律按团队"意味着任何非macro|、非bot|的输入都要先过长 UUID 校验。团队解析函数 parse_hyphenated_uuid 做了三重检查:长度必须等于 36、字符必须是 ASCII 十六进制或-、最后才交给Uuid::parse_str。这套前置校验让错误尽早暴露,避免把非 UUID 的字符串当作团队 ID 传播出去。

反向操作由principal_id()与Display提供,二者语义一致:

/// Canonical principal string for this owner. pub fn principal_id(&self) -> String { match self { Self::User(user_id) => user_id.to_string(), Self::Bot(bot_id) => bot_id.into_storage_id().to_string(), Self::Team(team_id) => team_id.hyphenated().to_string(), } }

见 lib.rs#L95-L103 与 lib.rs#L152-L160。机器人的into_storage_id()来自 bot_id,它会重新拼出bot|<uuid>字符串;团队的hyphenated()保证输出始终是带连字符形式,而不是 32 位紧凑形式。

2.3 严格解析的反面用例

src/test.rs 中的测试把"严格"落实为可执行断言,这些用例正是接入方最容易踩的坑:

测试断言内容
parse_rejects_bot_principal_as_userOwner::parse(User, "bot\|…")必须失败
parse_rejects_user_principal_as_botOwner::parse(Bot, "macro\|…")必须失败
parse_rejects_bare_uuid_as_bot裸 UUID 不能被当作机器人
parse_rejects_bot_principal_as_teambot\|…不能被当作团队
parse_rejects_malformed_uuidsbot\|not-a-uuid、not-a-uuid均失败
parse_rejects_non_canonical_uuids32 位无连字符 UUID 被拒绝

其中"非规范 UUID 被拒绝"这一点尤其值得强调:0123456789abcdef0123456789abcdef在语义上是合法 UUID,但因为不满足 36 位带连字符的长度约束,Owner::parse(Team, …)仍然报错。这保证了同一个归属者在系统里只有一种字符串表示,避免同一团队出现两种 ID 形态导致索引与比较失效。

2.4 序列化:透明字符串 + 反序列化时解析

Owner上的#[serde(into = "String", try_from = "String")](lib.rs#L41-L42)意味着它在 JSON 里就是一个普通字符串,而不是结构体。这一选择对 API 与 GraphQL 层非常友好,同时通过impl TryFrom<String>(lib.rs#L174-L180)在反序列化时强制走from_principal_str,从而把校验前移到边界。

测试 user_round_trip 明确断言serde_json::to_value(&owner)等于Value::String("macro|hutch@macro.com"),机器人与团队同样如此(test.rs#L19-L50)。

2.5 数据库侧:OwnerType 与entity_owner_type

OwnerType是为sqlx类型entity_owner_type准备的封闭字符串集合,同时充当 wire 层判别符(lib.rs#L52-L67):

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] #[cfg_attr(feature = "sqlx", derive(sqlx::Type))] #[cfg_attr( feature = "sqlx", sqlx(type_name = "entity_owner_type", rename_all = "lowercase") )] pub enum OwnerType { User, Bot, Team }

它有三条互相印证的约束:

  1. serde 输出小写字符串user/bot/team;
  2. Display/FromStr也一样只在三个小写值之间往返,其他输入返回OwnerParseError(lib.rs#L182-L203,测试见 test.rs#L84-L105);
  3. 只有启用sqlxfeature 才派生sqlx::Type,并绑定到 PG 枚举名entity_owner_type。

数据库侧的枚举与约束在 迁移 20260910144347_add_entity_table.sql 中定义:

CREATE TYPE entity_owner_type AS ENUM ('user', 'bot', 'team'); CREATE TABLE entity ( id UUID PRIMARY KEY, entity_type TEXT NOT NULL CHECK (entity_type IN ('project','document','chat','agent_session','scheduled_action')), owner_type entity_owner_type NOT NULL, owner_id TEXT NOT NULL, ... CONSTRAINT entity_owner_id_matches_type CHECK ( (owner_type = 'user' AND owner_id LIKE 'macro|%') OR (owner_type = 'bot' AND owner_id LIKE 'bot|%') OR (owner_type = 'team' AND owner_id ~ '^[0-9a-f-]{36}$') ) );

entity_owner_id_matches_type这条 CHECK 是 Rust 端三套 principal 格式在数据库侧的镜像。需要注意两者的严格程度不同:Rust 侧团队 ID 会做长度 + 十六进制字符 + 真实 UUID 解析三重校验,而 SQL 侧只做^[0-9a-f-]{36}$的形状匹配。因此写库时不应绕过 Rust 的Owner类型直接把字符串塞进owner_id。

三、CreationPrincipal:谁创建、谁拥有、谁执行

3.1 三种创建场景

CreationPrincipal定义在 src/creation_principal.rs,其核心设计动机写在类型文档里:"Owner、actor、acting user 三者都从这一个值派生,因此不可能互相矛盾。"

变体语义拥有者执行用户执行机器人
User(user)用户为自己创建该用户Some(user)None
BotForUser { bot, user }机器人代表已验证用户创建该用户Some(user)Some(bot)
TeamBot { bot, team }团队作用域机器人,无执行用户该机器人NoneSome(bot)

派生逻辑非常直白(creation_principal.rs#L36-L63):

pub fn owner(&self) -> Owner { match self { Self::User(user) | Self::BotForUser { user, .. } => Owner::User(user.clone()), Self::TeamBot { bot, .. } => Owner::Bot(bot.get()), } }

TeamBot使用NonSystemBotId而非裸BotId,这是一个重要约束:bot_id 中的NonSystemBotId::new只在 ID 不属于第一方系统机器人时返回Some。换言之,系统机器人不能作为无用户归属的拥有者,它必须始终"代表某个用户"行事。

测试 src/creation_principal/test.rs 三个用例分别验证了这三条路径,其中team_bot_owns_without_a_user断言principal.user()为None、owner().principal_id()等于bot|00000000-0000-0000-0000-00000000b07a。

3.2 从鉴权结果到创建主体

CreationPrincipal的构造并不在model_owner内部完成,而是在entity_registry的 resolve_creation_principal 中,由MacroAuthorization推导:

pub fn resolve_creation_principal( authorization: &MacroAuthorization, non_user_owners: NonUserOwners, ) -> Result<CreationPrincipal, CreationPrincipalError> { match authorization { MacroAuthorization::User(user) | MacroAuthorization::Internal(Some(user)) => { Ok(CreationPrincipal::User(user.macro_user_id.clone())) } MacroAuthorization::Bot(bot) => resolve_bot(bot, non_user_owners), MacroAuthorization::Internal(None) => Err(CreationPrincipalError::InternalWithoutUser), MacroAuthorization::Harness(_) => Err(CreationPrincipalError::Harness), } }

机器人分支的判定顺序(同文件 #L81-L102)值得逐条记住:

  1. 有acting_user→ 直接产出BotForUser,不检查开关;
  2. 无执行用户时,必须是非系统机器人,否则SystemBotWithoutUser;
  3. BotScope::User→UserScopeWithoutUser;
  4. BotScope::Team但无已验证团队 →MissingTeam;
  5. 团队作用域 + 有团队 +NonUserOwners::Disabled→NonUserOwnersDisabled;
  6. 全部满足才产出TeamBot。

NonUserOwners开关的配置解析刻意不读环境变量(creation_principal.rs#L24-L37):"true"开启,"false"或缺失关闭,其他值报ParseBoolError。这一点让开关的来源(配置系统)与语义解耦,也方便测试。

错误枚举的六个变体见 #L40-L60,每个都带一句可直接作为日志文案的错误信息,例如"a team-scoped bot has no verified team"。

3.3 归属展开:从 Owner 到授权来源

创建主体确定归属后,entity_registry的 OwnerGrantPolicy::grants_for 把Owner展开为若干(source_type, source_id)授权来源:

  • 用户与团队:只产生一条自身授权;
  • 普通机器人:产生机器人自身授权+其 sponsor(赞助者)授权;
  • 系统机器人:只产生自身授权,不要求存在持久化的 bot 行;
  • sponsor 缺失或指向另一个机器人:直接InvalidBotSponsor失败关闭(fail closed)。

这段逻辑与Owner/BotId的组合直接相关,也是"类型化归属"能安全支撑共享与可见性计算的原因。

四、owner_team:团队链接可见范围的双实现策略

4.1 Rust 侧:OwnerTeamFacts 与纯函数

src/team.rs是AGENTS.md里唯一被标注为 Gotcha 的模块。它接收一个Owner和三份"由仓储派生、而非调用方传入"的事实(team.rs#L13-L22):

/// Repository-derived membership and bot ownership facts, not caller-supplied teams. #[derive(Debug, Default, Clone, Copy)] pub struct OwnerTeamFacts { /// The user's membership for a user owner. pub user_team: Option<Uuid>, /// The bot's explicit team, if it is team-owned. pub bot_team: Option<Uuid>, /// The owning user's membership, if the bot is user-owned. pub bot_user_team: Option<Uuid>, }

策略函数只有五行(team.rs#L24-L35):

pub fn owner_team(owner: &Owner, facts: OwnerTeamFacts) -> Option<Uuid> { match owner { Owner::User(_) => facts.user_team, Owner::Team(team) => Some(*team), Owner::Bot(_) => facts.bot_team.or(facts.bot_user_team), } }

三条规则的完整语义:

Owner 类型可见受众来源边界行为
Userfacts.user_team(该用户当前所属团队)无团队 →None,即没有团队链接受众
Team团队自身恒为Some(team),即使 facts 全空
Bot优先facts.bot_team,回退facts.bot_user_team两者皆空(缺失机器人 / 无团队用户)→None

注意"用户保留其当前成员关系"这一表述:OwnerTeamFacts不是调用方随便传的入参,而是必须从仓储读取的事实,避免上层伪造团队归属。文档注释还特别声明了两点限制:解析受众从不授予归属,且不授权编辑共享设置。

src/team/test.rs 用一个精心构造的 facts 覆盖所有分支:user_team = Some(other)、bot_team = Some(team)、bot_user_team = Some(other),从而可以区分"机器人显式团队优先"与"回退到所属用户团队";同时断言Owner::Team(team)在OwnerTeamFacts::default()下依然返回Some(team)。

4.2 SQL 侧:owner_team 集合函数

同一策略在 PostgreSQL 中以集合函数实现,定义在 迁移 20260922200142_resolve_typed_owner_team.sql:

CREATE FUNCTION public.owner_team(principal text) RETURNS TABLE (team_id uuid) LANGUAGE sql STABLE PARALLEL SAFE AS $$ SELECT tu.team_id FROM public.team_user tu WHERE tu.user_id = principal AND principal NOT LIKE 'bot|%' AND principal !~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' UNION ALL SELECT CASE WHEN principal ~ '^[0-9a-fA-F]{8}-...$' THEN principal::uuid END WHERE principal ~ '^[0-9a-fA-F]{8}-...$' UNION ALL SELECT COALESCE(b.team_id, tu.team_id) FROM public.bots b LEFT JOIN public.team_user tu ON b.team_id IS NULL AND tu.user_id = b.owner_user_id WHERE b.id = CASE WHEN principal ~ '^bot\|...$' THEN substring(principal FROM 5)::uuid END AND COALESCE(b.team_id, tu.team_id) IS NOT NULL $$;

(上面为便于阅读省略了重复的 UUID 正则,完整正则见迁移文件第 14、16–18、23 行。)

三段UNION ALL与 Rust 的三个 match 分支一一对应:

  1. 用户分支:按user_id查team_user,但排除了形如bot|…的 principal 和形如 UUID 的 principal。注释里写明这是"保留历史用户 ID,但绝不把带类型的 bot/team 当作用户"。
  2. 团队分支:当 principal 自身是 UUID 时,直接把它作为team_id返回,且不依赖team_user表——正对应 Rust 的Owner::Team(team) => Some(*team)。
  3. 机器人分支:以b.id = substring(principal FROM 5)::uuid定位机器人行,用COALESCE(b.team_id, tu.team_id)实现"显式团队优先、回退到 owner 用户团队",再用IS NOT NULL过滤掉两者皆空的情况——与facts.bot_team.or(facts.bot_user_team)完全同构。

函数声明为STABLE PARALLEL SAFE,并且注释强调要保持"可内联"(inlinable):用户归属的查询依旧走team_user上的索引,不会退化成逐实体往返。这条性能取向是它被写成 SQL 函数而非应用层循环的关键原因。

4.3 为什么必须"两侧锁步"

AGENTS.md的 Gotcha 明确指出:OwnerTeamFacts必须与owner_team的集合逻辑保持锁步,任何行为变更都需要在两侧补上对应的适配测试。仓库里确实存在跨语言的对拍测试:crates/macro_db_client/src/share_permission/access_level/chat_tests.rs 中的typed_owner_team_links_match_batch_access,它加载 typed_owner_team 夹具,对三个用户与七份实体(31–37)逐一断言member && has_team才拥有Comment级别。

夹具中的实体覆盖了全部归属形态(typed_owner_team.sql#L35-L42):

文档 ID 后缀名称owner团队链接是否应命中
031Usermacro\|typed-owner@example.com是(该用户属于 owner 团队)
032Teamless usermacro\|typed-teamless@example.com否(无团队)
033Team90000000-…-011是(团队自身即受众)
034Team botbot\|…021(team_id= 011)是
035User botbot\|…022(owner_user_id= typed-owner)是
036Teamless botbot\|…023(owner 用户无团队)否
037Missing botbot\|…024(bots表无此行)否

夹具还刻意插入了一批"历史 principal 行"(#L19-L34),例如让团队 UUID 与 bot principal 在team_user里拥有另一团队的成员关系,并在注释中说明:"历史 principal 行的成员关系不得覆盖其类型化归属"。这正是 SQL 用户分支里那两条排除条件存在的原因。

4.4 真实调用点:会话批量访问级别

owner_team函数在查询里被用于判断linkShare = 'TEAM'的链接是否对当前访问者生效,见 chat.rs:

SELECT 1 FROM owner_team(c."userId") owner_team WHERE owner_team.team_id::text = ANY( SELECT source_id FROM user_source_ids )

这里的c."userId"是被查询会话(或文档/项目)的归属者 principal,而user_source_ids是访问者自己的来源集合(参与的信道、所属团队、自身 ID)。也就是说:函数把"归属者"映射成受众团队,再与"访问者"的来源集合求交——owner_team只负责前半段,身份比对由调用方完成。这与模块文档"Resolving an audience never confers ownership"完全一致。

五、在领域模型中的落地形态

modelcrate 直接以Owner作为字段类型,把类型化归属推进到 API 层。以文档为例,crates/model/src/document.rs 中:

/// The owner of the document #[schema(value_type = String)] #[sqlx(try_from = "String")] pub owner: Owner,

两个属性各有作用:

  • #[sqlx(try_from = "String")]让数据库里的text列在映射时自动走TryFrom<String>,即from_principal_str。由于Owner的 serde 也是字符串,读写两侧的表示完全统一;
  • #[schema(value_type = String)]让 OpenAPI schema 把该字段描述为字符串,与 JSON 实际形态一致,避免生成嵌套对象模型。

同样的模式出现在BasicDocument、项目模型等位置,Owner在modelcrate 中被activity/map_item.rs、chat.rs、document.rs、item/map_item.rs、project.rs等多处引用。这也是AGENTS.md所说"被几乎所有带类型化归属的领域 crate 使用"的具体体现。

另一处具体落地是活动归属:activitycrate 把CreationPrincipal映射为Attribution(crates/activity/src/domain/models.rs#L91-L101):

CreationPrincipal::User(user) => Self::direct(Actor::new_from_user(user.clone())), CreationPrincipal::BotForUser { bot, user } => { Self::delegated(Actor::new_from_bot(*bot), user.clone()) } CreationPrincipal::TeamBot { bot, .. } => Self::direct(Actor::new_from_bot(bot.get())),

BotForUser映射为delegated(机器人执行、用户为发起人),TeamBot映射为direct(机器人自己就是直接执行者)。这条映射说明:CreationPrincipal不只是归属计算工具,也是审计与活动流的语义来源——它保证"记录的操作者"与"记录的所有者"不会互相冲突。

六、验证与运行

AGENTS.md给出的检查命令很简短,但有一个容易忽略的前提:

# 基础单元测试(SQLX_OFFLINE 需未设置) cargo test -p model_owner # 覆盖 Postgres 类型实现(sqlx feature) cargo test -p model_owner --features sqlx

SQLX_OFFLINE之所以要取消,是因为该 crate 本身不含查询、但--features sqlx会拉起 sqlx 的类型派生,离线元数据环境会干扰编译流程。若只想验证纯逻辑(解析规则、把握团队受众),第一条命令即可;只有在需要确认OwnerType与entity_owner_type的映射时,才需要开启 feature。

涉及 SQL 对拍的测试属于macro_db_client,需要真实 Postgres 实例(sqlx test 宏会为每个用例建库)。例如:

cargo test -p macro_db_client typed_owner_team_links_match_batch_access

修改owner_team策略时,建议的验证顺序是:先跑model_owner::team的单元测试确认 Rust 语义,再跑上述 SQL 对拍测试确认集合实现,最后检查 chat_tests.rs 中基于 TEAM 链接的批量访问用例是否仍然通过。

七、易错点清单

  1. 不要用裸 UUID 表示机器人。Owner::parse(Bot, "0123…")必然失败,反之裸 UUID 只会被解释为团队。
  2. 不要用 32 位紧凑 UUID 表示团队。长度检查会直接拒绝,即使Uuid::parse_str本身能接受。
  3. 不要混淆"受众"与"鉴权"。owner_team只回答"这个归属者的团队链接面向哪些团队",最终访问级别由entity_access、models_permissions决定。
  4. 不要让调用方传入OwnerTeamFacts的团队值。三个字段必须来自仓储读取,否则等于把归属伪造权交给请求方。
  5. 改 SQL 不改 Rust,或反之。team.rs与owner_team(principal)是同一策略的两种实现;typed_owner_team.sql夹具中的历史 principal 行就是为捕捉不对称行为而存在的。
  6. 系统机器人不是合法的无用户归属者。NonSystemBotId在类型层面就挡住了;若绕过它直接构造Owner::Bot,OwnerGrantPolicy会走"系统机器人只发自身授权"的分支,与CreationPrincipal的语义不一致。
  7. 数据库 CHECK 比 Rust 宽松。entity_owner_id_matches_type的正则只是形状匹配,写库前必须经过Owner的类型化解析。

八、小结

model_owner看起来只是一个包含Owner、OwnerType、CreationPrincipal的小 crate,但它把三件容易混淆的事拆得足够干净:

  • 值层面:谁拥有 —— 用户、机器人、团队三值封闭类型,配一套严格且唯一的 principal 字符串规范;
  • 行为层面:谁创建、谁执行、谁拥有 ——CreationPrincipal让三者从同一个值派生,消除不一致;
  • 可见性层面:团队链接面向谁 —— 一个纯函数加一个 SQL 集合函数,两条实现被夹具强制对齐。

理解了这三层,再去看entity表的owner_type/owner_id约束、model里#[sqlx(try_from = "String")]的Owner字段,以及share_permission中owner_team(c."userId")的用法,就能把它们串成一条完整的"归属 → 授权来源 → 可见范围"链路,而不会在"拥有者是否等于操作者""团队链接是否等于编辑权限"这类边界问题上出错。

  • 后端
  • 前端
  • 协同办公
  • 即时通讯
  • 企业应用
  • CRM
  • AI Agent
  • 人工智能

【免费下载链接】macro

Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.

项目地址:https://gitcode.com/GitHub_Trending/macro3/macro
点击查看免费下载

相关推荐

上一篇:LeetCode 857 雇佣 K 名工人的最低成本:贪心 + 堆(优先队列)完整题解 | AlgoNote 算法通关手册
下一篇:devenv 中 `path:` 本地输入变更的评估缓存失效机制详解

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进