fix(profiles): stop losing the first upload to a new profiles stream under RBAC (#15956)

This commit is contained in:
Huaijin
2026-10-29 07:01:02 +00:00
committed by GitHub
parent 3af44a19f6
commit 62b763e686
11 changed files with 150 additions and 30 deletions
+1
View File
@@ -389,6 +389,7 @@ where
StreamType::Logs => RpStreamType::Logs,
StreamType::Metrics => RpStreamType::Metrics,
StreamType::Traces => RpStreamType::Traces,
StreamType::Profiles => RpStreamType::Profiles,
StreamType::EnrichmentTables => RpStreamType::EnrichmentTables,
_ => RpStreamType::Logs,
}),
+30 -1
View File
@@ -33,7 +33,7 @@ use o2_enterprise::enterprise::{
use o2_openfga::{
authorizer::authz::{
add_tuple_for_pipeline, get_add_user_to_org_tuples, get_org_creation_tuples,
get_ownership_all_org_tuple, get_ownership_tuple, update_tuples,
get_ownership_all_org_tuple, get_ownership_tuple, get_parent_tuple, update_tuples,
},
meta::mapping::{
LOGS_INSIGHTS_KEY, LOGS_PATTERN_KEY, NON_OWNING_ORG, OFGA_MODELS, OFGAModel,
@@ -80,6 +80,7 @@ struct PendingMigrations {
fleet: bool,
public_dashboards: bool,
missing_ownership: bool,
profiles: bool,
}
pub async fn init() -> Result<(), anyhow::Error> {
@@ -343,6 +344,14 @@ pub async fn init() -> Result<(), anyhow::Error> {
&mut tuples,
);
}
if pending.profiles {
get_parent_tuple(
&format!("_all_{org_name}"),
"stream",
&format!("profiles:_all_{org_name}"),
&mut tuples,
);
}
}
run_folder_and_data_migrations(&pending).await;
}
@@ -563,6 +572,9 @@ fn all_org_ownership_keys(pending: &PendingMigrations) -> Vec<&'static str> {
if pending.public_dashboards {
keys.push("public_dashboards");
}
if pending.profiles {
keys.push("profiles");
}
keys
}
@@ -749,6 +761,11 @@ fn pending_migrations(latest: &str, existing: &str) -> PendingMigrations {
log::info!("[OFGA:Local] public dashboards permissions migration needed");
pending.public_dashboards = true;
}
let v0_0_61 = version_compare::Version::from("0.0.61").unwrap();
if meta_version >= v0_0_61 && existing_model_version < v0_0_61 {
log::info!("[OFGA:Local] profiles stream permissions migration needed");
pending.profiles = true;
}
pending
}
@@ -855,4 +872,16 @@ mod tests {
};
assert_eq!(all_org_ownership_keys(&pending), vec!["public_dashboards"]);
}
#[test]
fn profiles_back_fill_runs_once_across_0_0_61() {
assert!(pending_migrations("0.0.61", "0.0.60").profiles);
assert!(!pending_migrations("0.0.61", "0.0.61").profiles);
assert!(!pending_migrations("0.0.60", "0.0.59").profiles);
assert_eq!(
all_org_ownership_keys(&pending_migrations("0.0.61", "0.0.60")),
vec!["profiles"]
);
assert!(OFGA_MODELS.contains_key("profiles"));
}
}
+58 -23
View File
@@ -18,13 +18,10 @@ use common::meta::authz::Authz;
#[cfg(feature = "enterprise")]
pub async fn set_ownership(org_id: &str, obj_type: &str, obj: Authz) {
if o2_openfga::config::get_config().enabled {
use o2_openfga::{authorizer, meta::mapping::OFGA_MODELS};
use o2_openfga::authorizer;
let obj_str = format!("{}:{}", OFGA_MODELS.get(obj_type).unwrap().key, obj.obj_id);
let parent_type = if obj.parent_type.is_empty() {
""
} else {
OFGA_MODELS.get(obj.parent_type.as_str()).unwrap().key
let Some((obj_str, parent_type)) = ofga_object(obj_type, &obj) else {
return;
};
// Default folder is already created for new organizations. This also
@@ -55,30 +52,20 @@ pub fn ownership_tuples(
obj_type: &str,
obj: &Authz,
) -> Vec<o2_openfga::authorizer::authz::TupleKey> {
use o2_openfga::{authorizer, meta::mapping::OFGA_MODELS};
let obj_str = format!("{}:{}", OFGA_MODELS.get(obj_type).unwrap().key, obj.obj_id);
let parent_type = if obj.parent_type.is_empty() {
""
} else {
OFGA_MODELS.get(obj.parent_type.as_str()).unwrap().key
let Some((obj_str, parent_type)) = ofga_object(obj_type, obj) else {
return vec![];
};
authorizer::authz::ownership_tuples(org_id, &obj_str, &obj.parent, parent_type)
o2_openfga::authorizer::authz::ownership_tuples(org_id, &obj_str, &obj.parent, parent_type)
}
#[cfg(feature = "enterprise")]
pub async fn remove_ownership(org_id: &str, obj_type: &str, obj: Authz) {
if o2_openfga::config::get_config().enabled {
use o2_openfga::{authorizer, meta::mapping::OFGA_MODELS};
let obj_str = format!("{}:{}", OFGA_MODELS.get(obj_type).unwrap().key, obj.obj_id);
let parent_type = if obj.parent_type.is_empty() {
""
} else {
OFGA_MODELS.get(obj.parent_type.as_str()).unwrap().key
let Some((obj_str, parent_type)) = ofga_object(obj_type, &obj) else {
return;
};
authorizer::authz::remove_ownership(org_id, &obj_str, &obj.parent, parent_type).await;
o2_openfga::authorizer::authz::remove_ownership(org_id, &obj_str, &obj.parent, parent_type)
.await;
}
}
@@ -139,3 +126,51 @@ pub async fn list_objects_for_user(
) -> anyhow::Result<Option<Vec<String>>> {
Ok(None)
}
/// OpenFGA object and parent type of `obj`; `None` when either type is unmapped.
#[cfg(feature = "enterprise")]
fn ofga_object(obj_type: &str, obj: &Authz) -> Option<(String, &'static str)> {
use o2_openfga::meta::mapping::OFGA_MODELS;
// Unmapped types must not panic: ingestion calls this after the schema is saved.
let Some(model) = OFGA_MODELS.get(obj_type) else {
log::error!(
"[OFGA] no model for type {obj_type}, ownership of {} not tracked",
obj.obj_id
);
return None;
};
let parent_type = if obj.parent_type.is_empty() {
""
} else if let Some(parent) = OFGA_MODELS.get(obj.parent_type.as_str()) {
parent.key
} else {
log::error!(
"[OFGA] no model for parent type {}, ownership of {obj_type}:{} not tracked",
obj.parent_type,
obj.obj_id
);
return None;
};
Some((format!("{}:{}", model.key, obj.obj_id), parent_type))
}
#[cfg(all(test, feature = "enterprise"))]
mod tests {
use super::*;
#[test]
fn test_ofga_object_skips_unmapped_types() {
assert!(ofga_object("no_such_type", &Authz::new("s")).is_none());
let mut obj = Authz::new("s");
obj.parent_type = "no_such_type".to_string();
obj.parent = "p".to_string();
assert!(ofga_object("logs", &obj).is_none());
assert_eq!(
ofga_object("logs", &Authz::new("s")),
Some(("logs:s".to_string(), ""))
);
}
}
+29 -2
View File
@@ -74,6 +74,7 @@ vi.mock("@/composables/useStreams", () => ({
],
},
traces: { list: [{ name: "svc-a" }] },
profiles: { list: [{ name: "cpu-prof" }, { name: "mem-prof" }] },
index: { list: [{ name: "users" }] },
enrichment_tables: { list: [{ name: "geo" }] },
metadata: { list: [{ name: "meta" }] },
@@ -466,6 +467,14 @@ const SAVED_GRANTS_RESOURCE_CATALOG = [
top_level: false,
order: 3,
},
{
key: "profiles",
display_name: "Profiles",
has_entities: true,
parent: "stream",
top_level: false,
order: 80,
},
{ key: "dfolder", display_name: "Dash Folders", has_entities: true, top_level: true, order: 4 },
{
key: "dashboard",
@@ -1805,6 +1814,7 @@ describe("EditRole - module navigation", () => {
"index",
"logs",
"metrics",
"profiles",
"traces",
]);
});
@@ -1819,6 +1829,7 @@ describe("EditRole - module navigation", () => {
"index",
"logs",
"metrics",
"profiles",
"traces",
]);
});
@@ -2385,11 +2396,17 @@ async function loadStreamType(wrapper, type) {
}
describe("EditRole - loaded rows through the tree [characterization]", () => {
it("getStreamsTypes builds four type rows", async () => {
it("getStreamsTypes builds five type rows", async () => {
const wrapper = await mountEditRole();
const stream = await loadResource(wrapper, "stream");
expect(stream.entities.map((e) => e.name)).toEqual(["logs", "traces", "metrics", "index"]);
expect(stream.entities.map((e) => e.name)).toEqual([
"logs",
"traces",
"metrics",
"profiles",
"index",
]);
expect(stream.entities[0]).toMatchObject({
name: "logs",
type: "Type",
@@ -3078,6 +3095,15 @@ describe("EditRole savedGrants - updateRolePermissions expansion", () => {
expect(rows.find((r) => r.name === "sys").permission.AllowGet.value).toBe(false);
});
it("expands the stream tree and ticks a per-stream profiles row", async () => {
const wrapper = await mountWithSaved([{ object: "profiles:cpu-prof", permission: "AllowGet" }]);
const rows = wrapper.vm.heavyResourceEntities["profiles"] ?? [];
const cpu = rows.find((r) => r.name === "cpu-prof");
expect(cpu.resourceName).toBe("profiles");
expect(cpu.permission.AllowGet.value).toBe(true);
expect(rows.find((r) => r.name === "mem-prof").permission.AllowGet.value).toBe(false);
});
it("expands dfolder and its folder to tick a folder-scoped dashboard grant", async () => {
const wrapper = await mountWithSaved([
{ object: "dashboard:default/d1", permission: "AllowGet" },
@@ -3629,6 +3655,7 @@ describe("EditRole - all modules", () => {
"index",
"logs",
"metrics",
"profiles",
"traces",
]);
expect(wrapper.find('[data-test^="edit-role-module-pane-bulk-"]').exists()).toBe(false);
@@ -90,6 +90,7 @@ const GROUP_OF: Record<string, ModuleGroupId> = {
logs: "data",
metrics: "data",
traces: "data",
profiles: "data",
index: "data",
metadata: "data",
enrichment_table: "data",
@@ -10,6 +10,7 @@ const getStreamsMock = vi.hoisted(() =>
logs: { list: [{ name: "app" }, { name: "sys" }] },
metrics: { list: [{ name: "cpu" }] },
traces: { list: [{ name: "svc-a" }] },
profiles: { list: [{ name: "cpu-prof" }] },
index: { list: [{ name: "users" }] },
enrichment_tables: { list: [{ name: "geo" }] },
metadata: { list: [{ name: "meta" }] },
@@ -334,7 +335,7 @@ describe("useRoleEntityLoaders - getResourceEntities dispatch", () => {
});
describe("useRoleEntityLoaders - stream loaders", () => {
it("getStreamsTypes builds four type rows without a service call", async () => {
it("getStreamsTypes builds five type rows without a service call", async () => {
const { getStreamsTypes, updateResourceResource } = setup();
getStreamsMock.mockClear();
@@ -345,6 +346,7 @@ describe("useRoleEntityLoaders - stream loaders", () => {
"logs",
"traces",
"metrics",
"profiles",
"index",
]);
expect(updateResourceResource.mock.calls[0]).toEqual([
@@ -356,6 +358,9 @@ describe("useRoleEntityLoaders - stream loaders", () => {
"name",
]);
expect(updateResourceResource.mock.calls[3][3]).toEqual([
{ stream_type: "profiles", name: "menu.profiles" },
]);
expect(updateResourceResource.mock.calls[4][3]).toEqual([
{ stream_type: "index", name: "iam.indices" },
]);
});
@@ -374,26 +379,30 @@ describe("useRoleEntityLoaders - stream loaders", () => {
);
});
it("getMetrics, getTraces and getIndexStreams each ask for their own stream type", async () => {
const { getMetrics, getTraces, getIndexStreams, updateEntityEntities } = setup();
it("getMetrics, getTraces, getProfiles and getIndexStreams each ask for their own stream type", async () => {
const { getMetrics, getTraces, getProfiles, getIndexStreams, updateEntityEntities } = setup();
getStreamsMock.mockClear();
const metrics = row({ name: "metrics", childName: "metrics" });
const traces = row({ name: "traces", childName: "traces" });
const profiles = row({ name: "profiles", childName: "profiles" });
const index = row({ name: "index", childName: "index" });
await getMetrics(metrics as any);
await getTraces(traces as any);
await getProfiles(profiles as any);
await getIndexStreams(index as any);
expect(getStreamsMock.mock.calls).toEqual([
["metrics", false],
["traces", false],
["profiles", false],
["index", false],
]);
expect(updateEntityEntities.mock.calls.map((call) => call[2])).toEqual([
[{ name: "cpu" }],
[{ name: "svc-a" }],
[{ name: "cpu-prof" }],
[{ name: "users" }],
]);
});
@@ -96,6 +96,7 @@ export const useRoleEntityLoaders = (deps: LoaderDeps) => {
logs: getLogs,
metrics: getMetrics,
traces: getTraces,
profiles: getProfiles,
index: getIndexStreams,
alert: getAlerts,
template: getTemplates,
@@ -571,6 +572,16 @@ export const useRoleEntityLoaders = (deps: LoaderDeps) => {
});
};
const getProfiles = async (resource: Resource | Entity) => {
const profiles: any = await getStreams("profiles", false);
updateEntityEntities(resource, ["name"], profiles.list);
return new Promise((resolve) => {
resolve(true);
});
};
const getMetadataStreams = async () => {
const metadata: any = await getStreams("metadata", false);
@@ -586,6 +597,7 @@ export const useRoleEntityLoaders = (deps: LoaderDeps) => {
{ stream_type: "logs", name: t("common.logs") },
{ stream_type: "traces", name: t("common.traces") },
{ stream_type: "metrics", name: t("common.metrics") },
{ stream_type: "profiles", name: t("menu.profiles") },
{ stream_type: "index", name: t("iam.indices") },
];
@@ -810,6 +822,7 @@ export const useRoleEntityLoaders = (deps: LoaderDeps) => {
getIndexStreams,
getMetrics,
getTraces,
getProfiles,
getMetadataStreams,
getStreamsTypes,
getReportFolders,
@@ -141,6 +141,7 @@ export const useRoleJsonView = (deps: JsonViewDeps) => {
resource === "logs" ||
resource === "metrics" ||
resource === "traces" ||
resource === "profiles" ||
resource === "index"
) {
resourceDetails = resourceMapper.value["stream"].entities.find(
@@ -211,6 +212,7 @@ export const useRoleJsonView = (deps: JsonViewDeps) => {
resource === "logs" ||
resource === "metrics" ||
resource === "traces" ||
resource === "profiles" ||
resource === "index"
) {
resourceDetails = resourceMapper.value["stream"].entities.find(
@@ -264,7 +264,7 @@ describe("useRolePermissionRows - updateEntityEntities", () => {
it("mirrors the heavy stream types into heavyResourceEntities and nothing else", () => {
const { updateEntityEntities, heavyResourceEntities } = setup();
const node = (name: string) => ({ name, childName: name, entities: [] as any[] });
const nodes = ["logs", "metrics", "traces", "index", "dfolder"].map(node);
const nodes = ["logs", "metrics", "traces", "profiles", "index", "dfolder"].map(node);
nodes.forEach((n) => updateEntityEntities(n as any, ["name"], [{ name: n.name + "-1" }]));
@@ -272,6 +272,7 @@ describe("useRolePermissionRows - updateEntityEntities", () => {
"index",
"logs",
"metrics",
"profiles",
"traces",
]);
expect(heavyResourceEntities.value.logs.map((e: any) => e.name)).toEqual(["logs-1"]);
@@ -126,6 +126,7 @@ export const useRolePermissionRows = (deps: RowDeps) => {
entity.name === "logs" ||
entity.name === "metrics" ||
entity.name === "traces" ||
entity.name === "profiles" ||
entity.name === "index"
) {
heavyResourceEntities.value[entity.name] = [...entities];
@@ -226,6 +226,7 @@ export const useSavedGrantExpansion = (deps: ExpansionDeps) => {
resource === "logs" ||
resource === "metrics" ||
resource === "traces" ||
resource === "profiles" ||
resource === "index"
) {
const streamResource = resourceMapper["stream"].entities.find(