diff --git a/src/core/src/auth.rs b/src/core/src/auth.rs index 1055c5fb39..ac4f5e5b71 100644 --- a/src/core/src/auth.rs +++ b/src/core/src/auth.rs @@ -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, }), diff --git a/src/core/src/ofga/mod.rs b/src/core/src/ofga/mod.rs index f97e4a8d16..a14e29f55b 100644 --- a/src/core/src/ofga/mod.rs +++ b/src/core/src/ofga/mod.rs @@ -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")); + } } diff --git a/src/db/src/authz.rs b/src/db/src/authz.rs index 578a7e8746..e15fa4d278 100644 --- a/src/db/src/authz.rs +++ b/src/db/src/authz.rs @@ -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 { - 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>> { 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(), "")) + ); + } +} diff --git a/web/src/components/iam/roles/EditRole.spec.js b/web/src/components/iam/roles/EditRole.spec.js index 8688612f84..4b466b16f5 100644 --- a/web/src/components/iam/roles/EditRole.spec.js +++ b/web/src/components/iam/roles/EditRole.spec.js @@ -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); diff --git a/web/src/components/iam/roles/roleModules.ts b/web/src/components/iam/roles/roleModules.ts index 266d312856..7e785fefa0 100644 --- a/web/src/components/iam/roles/roleModules.ts +++ b/web/src/components/iam/roles/roleModules.ts @@ -90,6 +90,7 @@ const GROUP_OF: Record = { logs: "data", metrics: "data", traces: "data", + profiles: "data", index: "data", metadata: "data", enrichment_table: "data", diff --git a/web/src/composables/iam/useRoleEntityLoaders.spec.ts b/web/src/composables/iam/useRoleEntityLoaders.spec.ts index eaa33dd052..dbfc06c4a5 100644 --- a/web/src/composables/iam/useRoleEntityLoaders.spec.ts +++ b/web/src/composables/iam/useRoleEntityLoaders.spec.ts @@ -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" }], ]); }); diff --git a/web/src/composables/iam/useRoleEntityLoaders.ts b/web/src/composables/iam/useRoleEntityLoaders.ts index 84a882c451..6848b1cede 100644 --- a/web/src/composables/iam/useRoleEntityLoaders.ts +++ b/web/src/composables/iam/useRoleEntityLoaders.ts @@ -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, diff --git a/web/src/composables/iam/useRoleJsonView.ts b/web/src/composables/iam/useRoleJsonView.ts index a277692107..c264710373 100644 --- a/web/src/composables/iam/useRoleJsonView.ts +++ b/web/src/composables/iam/useRoleJsonView.ts @@ -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( diff --git a/web/src/composables/iam/useRolePermissionRows.spec.ts b/web/src/composables/iam/useRolePermissionRows.spec.ts index 2567f4d24a..71f42127be 100644 --- a/web/src/composables/iam/useRolePermissionRows.spec.ts +++ b/web/src/composables/iam/useRolePermissionRows.spec.ts @@ -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"]); diff --git a/web/src/composables/iam/useRolePermissionRows.ts b/web/src/composables/iam/useRolePermissionRows.ts index ec689f3a69..28a1092f81 100644 --- a/web/src/composables/iam/useRolePermissionRows.ts +++ b/web/src/composables/iam/useRolePermissionRows.ts @@ -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]; diff --git a/web/src/composables/iam/useSavedGrantExpansion.ts b/web/src/composables/iam/useSavedGrantExpansion.ts index e78c9fbced..0cab56f756 100644 --- a/web/src/composables/iam/useSavedGrantExpansion.ts +++ b/web/src/composables/iam/useSavedGrantExpansion.ts @@ -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(