Skip to content

fix(m365): exclude unlicensed users from collaboration silver feeders - #1869

Merged
mitasovr merged 2 commits into
constructorfabric:mainfrom
mitasovr:claude/check-issue-736-642b95
Jul 23, 2026
Merged

fix(m365): exclude unlicensed users from collaboration silver feeders#1869
mitasovr merged 2 commits into
constructorfabric:mainfrom
mitasovr:claude/check-issue-736-642b95

Conversation

@mitasovr

@mitasovr mitasovr commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Unlicensed M365 users (guests, ex-employees, service accounts) leak into the class_collab_* silver feeders because none of the M365 feeders filter on license status. This inflates team-level collaboration counters and produces orphan gold rows with no matching insight.people entry.

Fixes #736.

Approach — filter at the silver feeder (not propagate)

The filter is applied in the feeder WHERE clause rather than carrying a license flag into silver for downstream consumers to apply. Rationale:

  • silver.class_collab_* is built via union_by_tag('SELECT *'), which requires an identical column set across all feeders. Propagating a column would force it onto every feeder (incl. zoom, which has no license concept) and onto the silver→gold contract.
  • Silver is the clean business contract; an unlicensed actor is noise, and the feeder is the natural boundary to drop it.
  • Nothing is lost: bronze retains every raw row (incl. isLicensed=false), so per-tenant audit of dropped rows is still possible.

Column set unchanged → the zoom union is unaffected.

What changed

Feeders (src/ingestion/connectors/collaboration/m365/dbt/):

  • meeting + chat (both read teams_activity): AND coalesce(isLicensed, true) = true. isLicensed = "licensed to use Teams" per MS Graph getTeamsUserActivityUserDetail.
  • email / onedrive / sharepoint: those report details expose no isLicensed; the only license signal is assignedProducts (empty for unlicensed accounts). Airbyte stores the array as a JSON string, so the filter drops empty lists ('[]' / '').
  • Conservative on NULL/unknown (keep); only proven-unlicensed rows are dropped — safe no-op if the serialization ever differs.

Bronze placeholder DDL (create-bronze-placeholders.sh): add isLicensed (teams), assignedProducts (onedrive, sharepoint).

E2E: add the new columns to the teams/onedrive fixtures + templates (the rig strictly matches fixture columns to the placeholder table), plus a regression test collab_meeting_unlicensed_excluded.test.yaml — an unlicensed user (meetingsAttendedCount=99) is excluded (value null) while a licensed control flows through (value 3).

Testing

Ran the affected metric suite in the dockerized e2e rig:

11 passed, 133 deselected in 395.98s

(new exclusion test + all existing teams/onedrive/email collab metric tests)

Note for reviewers

The assignedProducts empty-list serialization ('[]') is inferred from the e2e fixture typing (string) and Airbyte's ClickHouse-destination behavior; it was not verified against a live prod DB. The filter is written with a safe failure mode — if the real format differs it drops nothing rather than over-filtering. The teams path (isLicensed, the concrete evidence in #736) is unambiguous.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Excluded unlicensed Microsoft 365 users from collaboration meeting, chat, email, OneDrive, and SharePoint activity metrics.
    • Improved tenant matching for identity data across supported tenant ID formats.
    • Prevented inflated collaboration counts and orphaned metric records.
  • Tests

    • Added end-to-end coverage confirming unlicensed meeting participants are excluded.
  • Release

    • Updated the Insight chart and application version to 0.4.15.

@mitasovr
mitasovr requested a review from a team as a code owner July 23, 2026 07:13
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@mitasovr, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 37 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8e747ea7-a023-49cd-b96e-f7c1096a69f5

📥 Commits

Reviewing files that changed from the base of the PR and between cdc8b82a5f669aeae0d36597fadc75e321556c09 and 50cef2f.

📒 Files selected for processing (11)
  • src/ingestion/connectors/collaboration/m365/dbt/m365__collab_chat_activity.sql
  • src/ingestion/connectors/collaboration/m365/dbt/m365__collab_document_activity_onedrive.sql
  • src/ingestion/connectors/collaboration/m365/dbt/m365__collab_document_activity_sharepoint.sql
  • src/ingestion/connectors/collaboration/m365/dbt/m365__collab_email_activity.sql
  • src/ingestion/connectors/collaboration/m365/dbt/m365__collab_meeting_activity.sql
  • src/ingestion/scripts/create-bronze-placeholders.sh
  • src/ingestion/tests/e2e/metrics/collab_meeting_unlicensed_excluded.test.yaml
  • src/ingestion/tests/e2e/metrics/schemas/bronze_m365.onedrive_activity.yaml
  • src/ingestion/tests/e2e/metrics/schemas/bronze_m365.teams_activity.yaml
  • src/ingestion/tests/e2e/metrics/templates/m365_onedrive.yaml
  • src/ingestion/tests/e2e/metrics/templates/m365_teams.yaml
📝 Walkthrough

Walkthrough

Changes

M365 license filtering

Layer / File(s) Summary
M365 activity data contracts
src/ingestion/scripts/create-bronze-placeholders.sh, src/ingestion/tests/e2e/metrics/schemas/*, src/ingestion/tests/e2e/metrics/templates/*
Placeholder tables and test fixtures now include nullable isLicensed and assignedProducts fields.
Activity model license filters
src/ingestion/connectors/collaboration/m365/dbt/*activity.sql
Chat and meeting models filter isLicensed; email, OneDrive, and SharePoint models filter explicit empty assignedProducts values.
License exclusion regression coverage
src/ingestion/tests/e2e/metrics/collab_meeting_unlicensed_excluded.test.yaml
The end-to-end test checks that licensed meeting activity is retained and unlicensed activity produces no metric observation.

Identity tenant matching

Layer / File(s) Summary
Dual tenant representation query
src/backend/services/identity/src/Insight.Identity.Infrastructure/ClickHouse/ClickHouseIdentityInputsReader.cs
The ClickHouse tenant predicate matches both raw and sipHash128-derived dashed tenant identifiers.

Release metadata

Layer / File(s) Summary
Release version alignment
charts/insight/Chart.yaml, deploy/gitops/.insight-version, src/backend/services/identity/helm/Chart.yaml
Chart and GitOps metadata are updated to release version 0.4.15 and the new application build identifiers.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TeamsActivity
  participant M365DbtModels
  participant MetricResultsAPI
  TeamsActivity->>M365DbtModels: provide license fields
  M365DbtModels->>M365DbtModels: filter explicitly unlicensed activity
  M365DbtModels->>MetricResultsAPI: return filtered metric inputs
Loading

Possibly related PRs

Suggested reviewers: aleksdotbar

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR covers #736, but the Teams filter keeps NULL isLicensed rows instead of excluding them as proposed. Change the Teams predicate to coalesce(isLicensed, false) = true, or explain why NULLs must be retained.
Out of Scope Changes check ⚠️ Warning Chart/package version bumps and the gitops version file update are unrelated to the M365 feeder fix. Remove those release/version-only edits from this PR or document them as required scope.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the M365 fix to exclude unlicensed users from collaboration silver feeders.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mitasovr
mitasovr enabled auto-merge (squash) July 23, 2026 08:16
@mitasovr
mitasovr force-pushed the claude/check-issue-736-642b95 branch from 65c5be8 to cdc8b82 Compare July 23, 2026 08:46

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/ingestion/tests/e2e/metrics/collab_meeting_unlicensed_excluded.test.yaml (1)

14-16: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the NULL-license behavior.

The fixture only exercises true and false; it does not verify the stated conservative behavior for isLicensed: null. Add a third activity row with isLicensed: null and assert that its activity remains.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ingestion/tests/e2e/metrics/collab_meeting_unlicensed_excluded.test.yaml`
around lines 14 - 16, Extend the bronze_m365.teams_activity fixture with a third
user whose isLicensed value is null, then update the expected metrics assertions
to verify this user’s meeting activity remains included. Preserve the existing
true and false license cases and use a distinct user identity and unique_key.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/ingestion/connectors/collaboration/m365/dbt/m365__collab_meeting_activity.sql`:
- Around line 45-51: Add a one-time cleanup or explicit incremental deletion for
rows now excluded by the new license predicates, covering all five incremental
models:
src/ingestion/connectors/collaboration/m365/dbt/m365__collab_meeting_activity.sql
(lines 45-51), m365__collab_chat_activity.sql (lines 70-76),
m365__collab_email_activity.sql (lines 34-42),
m365__collab_document_activity_onedrive.sql (lines 39-47), and
m365__collab_document_activity_sharepoint.sql (lines 39-47). Remove previously
materialized activity with isLicensed = false or explicitly empty
assignedProducts while preserving the new filtering for future incremental runs.

In `@src/ingestion/scripts/create-bronze-placeholders.sh`:
- Line 1246: Add idempotent ALTER TABLE ... ADD COLUMN IF NOT EXISTS handling
for isLicensed and assignedProducts in the existing-table branches of the M365
bronze table setup, including the paths corresponding to all three referenced
column definitions. Preserve the current CREATE TABLE definitions for fresh
tables while ensuring warm databases reconcile missing columns before dbt models
query them.

---

Nitpick comments:
In
`@src/ingestion/tests/e2e/metrics/collab_meeting_unlicensed_excluded.test.yaml`:
- Around line 14-16: Extend the bronze_m365.teams_activity fixture with a third
user whose isLicensed value is null, then update the expected metrics assertions
to verify this user’s meeting activity remains included. Preserve the existing
true and false license cases and use a distinct user identity and unique_key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 916a9b72-ab0c-4178-838a-773bcf8de6cd

📥 Commits

Reviewing files that changed from the base of the PR and between 346d39e and cdc8b82a5f669aeae0d36597fadc75e321556c09.

📒 Files selected for processing (15)
  • charts/insight/Chart.yaml
  • deploy/gitops/.insight-version
  • src/backend/services/identity/helm/Chart.yaml
  • src/backend/services/identity/src/Insight.Identity.Infrastructure/ClickHouse/ClickHouseIdentityInputsReader.cs
  • src/ingestion/connectors/collaboration/m365/dbt/m365__collab_chat_activity.sql
  • src/ingestion/connectors/collaboration/m365/dbt/m365__collab_document_activity_onedrive.sql
  • src/ingestion/connectors/collaboration/m365/dbt/m365__collab_document_activity_sharepoint.sql
  • src/ingestion/connectors/collaboration/m365/dbt/m365__collab_email_activity.sql
  • src/ingestion/connectors/collaboration/m365/dbt/m365__collab_meeting_activity.sql
  • src/ingestion/scripts/create-bronze-placeholders.sh
  • src/ingestion/tests/e2e/metrics/collab_meeting_unlicensed_excluded.test.yaml
  • src/ingestion/tests/e2e/metrics/schemas/bronze_m365.onedrive_activity.yaml
  • src/ingestion/tests/e2e/metrics/schemas/bronze_m365.teams_activity.yaml
  • src/ingestion/tests/e2e/metrics/templates/m365_onedrive.yaml
  • src/ingestion/tests/e2e/metrics/templates/m365_teams.yaml

Comment on lines +45 to +51
-- Drop unlicensed users (guests, ex-employees, service accounts). Their Teams
-- activity inflates team-level collab counters and produces orphan gold rows
-- with no matching insight.people entry. `isLicensed` = "Selected if the user
-- is licensed to use Teams" (MS Graph getTeamsUserActivityUserDetail). Only the
-- teams_activity report exposes this flag; the email/onedrive/sharepoint feeders
-- fall back to `assignedProducts`. Conservative on NULL (unknown -> keep). See #736.
AND coalesce(isLicensed, true) = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
git ls-files | rg 'src/ingestion/connectors/collaboration/m365/dbt/(m365__collab_meeting_activity|m365__collab_chat_activity|m365__collab_email_activity|m365__collab_document_activity_onedrive|m365__collab_document_activity_sharepoint)\.sql$'

echo
echo "== file outlines/contents relevant lines =="
for f in \
  src/ingestion/connectors/collaboration/m365/dbt/m365__collab_meeting_activity.sql \
  src/ingestion/connectors/collaboration/m365/dbt/m365__collab_chat_activity.sql \
  src/ingestion/connectors/collaboration/m365/dbt/m365__collab_email_activity.sql \
  src/ingestion/connectors/collaboration/m365/dbt/m365__collab_document_activity_onedrive.sql \
  src/ingestion/connectors/collaboration/m365/dbt/m365__collab_document_activity_sharepoint.sql
do
  echo "--- $f ---"
  wc -l "$f"
  sed -n '1,140p' "$f" | cat -n
done

echo
echo "== dbt incremental / unique keys across repo =="
rg -n "{% set|unique_key|incremental_strategy|insert_overwrite|merge|where\s*\(\).*incremental|unique_key|unique_key" . -g '*.sql' -g '*.yml' -g '*.yaml' | head -n 250

Repository: constructorfabric/insight

Length of output: 20094


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
git ls-files | rg 'src/ingestion/connectors/collaboration/m365/dbt/(m365__collab_meeting_activity|m365__collab_chat_activity|m365__collab_email_activity|m365__collab_document_activity_onedrive|m365__collab_document_activity_sharepoint)\.sql$'

echo
echo "== file relevant contents =="
for f in \
  src/ingestion/connectors/collaboration/m365/dbt/m365__collab_meeting_activity.sql \
  src/ingestion/connectors/collaboration/m365/dbt/m365__collab_chat_activity.sql \
  src/ingestion/connectors/collaboration/m365/dbt/m365__collab_email_activity.sql \
  src/ingestion/connectors/collaboration/m365/dbt/m365__collab_document_activity_onedrive.sql \
  src/ingestion/connectors/collaboration/m365/dbt/m365__collab_document_activity_sharepoint.sql
do
  echo "--- $f ---"
  wc -l "$f"
  sed -n '1,140p' "$f" | cat -n
done

echo
echo "== dbt incremental / unique key indicators =="
rg -n "{% set|unique_key|incremental_strategy|insert_overwrite|merge|where\s*\(\).*incremental|unique_key|unique_key" . -g '*.sql' -g '*.yml' -g '*.yaml' | head -n 250 || true

Repository: constructorfabric/insight

Length of output: 20077


Add a one-time cleanup for newly excluded unlicensed activity.

These models are materialized='incremental', so the new license predicates filter future source selections but do not remove rows already materialized for isLicensed = false or explicitly empty assignedProducts. Add a cleanup/full refresh or explicit incremental deletion for all five models.

📍 Affects 5 files
  • src/ingestion/connectors/collaboration/m365/dbt/m365__collab_meeting_activity.sql#L45-L51 (this comment)
  • src/ingestion/connectors/collaboration/m365/dbt/m365__collab_chat_activity.sql#L70-L76
  • src/ingestion/connectors/collaboration/m365/dbt/m365__collab_email_activity.sql#L34-L42
  • src/ingestion/connectors/collaboration/m365/dbt/m365__collab_document_activity_onedrive.sql#L39-L47
  • src/ingestion/connectors/collaboration/m365/dbt/m365__collab_document_activity_sharepoint.sql#L39-L47
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/ingestion/connectors/collaboration/m365/dbt/m365__collab_meeting_activity.sql`
around lines 45 - 51, Add a one-time cleanup or explicit incremental deletion
for rows now excluded by the new license predicates, covering all five
incremental models:
src/ingestion/connectors/collaboration/m365/dbt/m365__collab_meeting_activity.sql
(lines 45-51), m365__collab_chat_activity.sql (lines 70-76),
m365__collab_email_activity.sql (lines 34-42),
m365__collab_document_activity_onedrive.sql (lines 39-47), and
m365__collab_document_activity_sharepoint.sql (lines 39-47). Remove previously
materialized activity with isLicensed = false or explicitly empty
assignedProducts while preserving the new filtering for future incremental runs.

@@ -1243,6 +1243,7 @@ CREATE TABLE IF NOT EXISTS bronze_m365.teams_activity (
reportRefreshDate Nullable(String),
reportPeriod Nullable(String),
lastActivityDate Nullable(String),
isLicensed Nullable(Bool),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reconcile existing M365 bronze tables, not only fresh placeholders.

These columns are added only when the table is created. On a warm ClickHouse/E2E database, ch_table_exists skips the CREATE TABLE, leaving older tables without isLicensed or assignedProducts; the new dbt models then fail with UNKNOWN_IDENTIFIER. Add idempotent ALTER TABLE ... ADD COLUMN IF NOT EXISTS branches for existing tables.

Also applies to: 1310-1310, 1334-1334

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ingestion/scripts/create-bronze-placeholders.sh` at line 1246, Add
idempotent ALTER TABLE ... ADD COLUMN IF NOT EXISTS handling for isLicensed and
assignedProducts in the existing-table branches of the M365 bronze table setup,
including the paths corresponding to all three referenced column definitions.
Preserve the current CREATE TABLE definitions for fresh tables while ensuring
warm databases reconcile missing columns before dbt models query them.

Unlicensed M365 users (guests, ex-employees, service accounts) leaked into the
class_collab_* silver feeders, inflating team-level collaboration counters and
producing orphan gold rows with no matching insight.people entry (constructorfabric#736).

Filter at the silver feeder rather than propagating the flag: silver is the clean
business contract, and the union_by_tag('SELECT *') shape would otherwise force the
column onto every feeder (incl. zoom) and the silver->gold contract. Bronze retains
the raw rows for audit, so nothing is lost.

- teams feeders (meeting, chat): coalesce(isLicensed, true) = true. isLicensed =
  "licensed to use Teams" per MS Graph getTeamsUserActivityUserDetail.
- email/onedrive/sharepoint: these reports expose no isLicensed, so filter on an
  empty assignedProducts list (Airbyte stores the array as a JSON string, so an
  empty list serializes to '[]').
- Conservative on NULL/unknown (keep); drop only proven-unlicensed rows.

Also add isLicensed / assignedProducts to the bronze placeholder DDL and the e2e
fixtures + templates, plus a regression test asserting an unlicensed user is
excluded from the meeting metric while a licensed control flows through.

Fixes constructorfabric#736

Signed-off-by: Roman Mitasov <Roman.Mitasov@constructor.tech>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mitasovr
mitasovr force-pushed the claude/check-issue-736-642b95 branch from cdc8b82 to 933e308 Compare July 23, 2026 08:50
@mitasovr
mitasovr merged commit 8f6057d into constructorfabric:main Jul 23, 2026
34 checks passed
mitasovr added a commit that referenced this pull request Jul 23, 2026
…1875)

Follow-up to #736 (shipped in #1869), addressing the CodeRabbit review.

1. Teams feeders (meeting, chat): switch `coalesce(isLicensed, true)` to
   `coalesce(isLicensed, false) = true`, so rows with a NULL/unknown license
   flag are dropped alongside explicit `isLicensed=false` — matching the #736
   proposal. The e2e teams fixture template now defaults `isLicensed: true` so
   existing fixtures stay counted; the regression test adds an `isLicensed=null`
   case asserting it is excluded.

   The email/onedrive/sharepoint feeders keep their conservative
   `assignedProducts` filter (drop only explicitly-empty lists, keep NULL): those
   reports expose no `isLicensed`, and the JSON-string serialization is inferred
   rather than verified, so a NULL there means "unknown/unsynced" and must not
   nuke the whole feeder.

2. descriptor.yaml: migrate version "2026.05.04" -> "1.0.0" (strict semver,
   ADR-0015). Because the current deployed value is non-semver, reconcile
   classifies this as a `migration` (republish + catalog re-discover, NO
   full-refresh) — it only establishes the semver baseline. A follow-up MAJOR
   bump (1.0.0 -> 2.0.0) will dispatch the one-shot `dbt --full-refresh` over
   `dbt_select` (tag:m365+) that purges collab rows materialized before the #736
   filter landed, since incremental models do not retro-delete already-written
   rows.

Refs #736.

Signed-off-by: Roman Mitasov <Roman.Mitasov@constructor.tech>
Co-authored-by: Roman Mitasov <Roman.Mitasov@constructor.tech>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

M365 silver: unlicensed users (isLicensed=false) leak into class_collab_meeting_activity

2 participants