From 68ec1c5d1e0b947436e68fff4db9de78b1b71570 Mon Sep 17 00:00:00 2001 From: Justin Gallardo Date: Tue, 24 Feb 2026 16:56:26 -0800 Subject: [PATCH 1/3] Checkin data generation --- pkg/client/data.go | 354 +++++++++++++++++++++++++++++++++++++------ pkg/config/config.go | 2 +- 2 files changed, 309 insertions(+), 47 deletions(-) diff --git a/pkg/client/data.go b/pkg/client/data.go index 1c924ad2..657be210 100644 --- a/pkg/client/data.go +++ b/pkg/client/data.go @@ -34,6 +34,222 @@ func (r *dbResource) String() string { return "Unknown" } +// Department definitions with proportional weights. +type departmentDef struct { + Name string + Weight int // Cumulative percentage boundary (0-100) + JobTitles []string +} + +// departments defines the org structure. Weights are cumulative boundaries: +// Engineering=0-34, Sales=35-54, Marketing=55-66, Finance=67-76, HR=77-86, Product=87-99. +var departments = []departmentDef{ + {Name: "Engineering", Weight: 35, JobTitles: []string{ + "Software Engineer", "Senior Software Engineer", "Staff Engineer", + "Engineering Manager", "QA Engineer", "DevOps Engineer", "Frontend Engineer", + }}, + {Name: "Sales", Weight: 55, JobTitles: []string{ + "Account Executive", "Sales Development Rep", "Sales Manager", + "Solutions Engineer", "Sales Operations Analyst", + }}, + {Name: "Marketing", Weight: 67, JobTitles: []string{ + "Marketing Manager", "Content Writer", "Growth Analyst", + "Brand Designer", "SEO Specialist", + }}, + {Name: "Finance", Weight: 77, JobTitles: []string{ + "Financial Analyst", "Accountant", "Controller", "FP&A Analyst", + }}, + {Name: "Human Resources", Weight: 87, JobTitles: []string{ + "HR Business Partner", "Recruiter", "Compensation Analyst", + "HR Manager", "People Operations", + }}, + {Name: "Product", Weight: 100, JobTitles: []string{ + "Product Manager", "Senior Product Manager", "Product Designer", + "UX Researcher", "Technical Writer", + }}, +} + +// appDef defines a SaaS application group with department-correlated access. +type appDef struct { + Name string + DeptCoverage map[string]int // department name -> coverage percentage (0-100) + NoisePct int // percentage of non-target dept users who also get access +} + +// appGroups defines the SaaS applications and their department access patterns. +// Coverage percentages control what fraction of users in each department get the app. +// NoisePct controls cross-department "outlier" access. +var appGroups = []appDef{ + // Universal apps - everyone gets these + {Name: "Google Workspace", DeptCoverage: map[string]int{ + "Engineering": 100, "Sales": 100, "Marketing": 100, + "Finance": 100, "Human Resources": 100, "Product": 100, + }}, + {Name: "Slack", DeptCoverage: map[string]int{ + "Engineering": 100, "Sales": 100, "Marketing": 100, + "Finance": 100, "Human Resources": 100, "Product": 100, + }}, + {Name: "Okta", DeptCoverage: map[string]int{ + "Engineering": 100, "Sales": 100, "Marketing": 100, + "Finance": 100, "Human Resources": 100, "Product": 100, + }}, + {Name: "1Password", DeptCoverage: map[string]int{ + "Engineering": 97, "Sales": 95, "Marketing": 95, + "Finance": 96, "Human Resources": 95, "Product": 96, + }}, + + // Engineering tools + {Name: "GitHub", DeptCoverage: map[string]int{"Engineering": 96, "Product": 35}, NoisePct: 3}, + {Name: "Jira", DeptCoverage: map[string]int{"Engineering": 93, "Product": 88, "Marketing": 25}, NoisePct: 4}, + {Name: "AWS Console", DeptCoverage: map[string]int{"Engineering": 88}, NoisePct: 2}, + {Name: "Datadog", DeptCoverage: map[string]int{"Engineering": 83}, NoisePct: 1}, + {Name: "PagerDuty", DeptCoverage: map[string]int{"Engineering": 74}, NoisePct: 1}, + {Name: "CircleCI", DeptCoverage: map[string]int{"Engineering": 68}, NoisePct: 1}, + + // Sales tools + {Name: "Salesforce", DeptCoverage: map[string]int{"Sales": 96}, NoisePct: 2}, + {Name: "HubSpot", DeptCoverage: map[string]int{"Sales": 91, "Marketing": 88}, NoisePct: 3}, + {Name: "Gong", DeptCoverage: map[string]int{"Sales": 87}, NoisePct: 2}, + {Name: "Outreach", DeptCoverage: map[string]int{"Sales": 78}, NoisePct: 1}, + {Name: "LinkedIn Sales Navigator", DeptCoverage: map[string]int{"Sales": 73}, NoisePct: 1}, + + // Marketing tools + {Name: "Google Analytics", DeptCoverage: map[string]int{"Marketing": 90, "Product": 82}, NoisePct: 3}, + {Name: "Figma", DeptCoverage: map[string]int{"Marketing": 91, "Product": 89, "Engineering": 22}, NoisePct: 3}, + {Name: "Canva", DeptCoverage: map[string]int{"Marketing": 83}, NoisePct: 2}, + {Name: "Mailchimp", DeptCoverage: map[string]int{"Marketing": 78}, NoisePct: 1}, + + // Finance tools + {Name: "NetSuite", DeptCoverage: map[string]int{"Finance": 96}, NoisePct: 1}, + {Name: "Expensify", DeptCoverage: map[string]int{"Finance": 92}, NoisePct: 2}, + {Name: "Stripe Dashboard", DeptCoverage: map[string]int{"Finance": 76, "Engineering": 28}, NoisePct: 2}, + {Name: "QuickBooks", DeptCoverage: map[string]int{"Finance": 69}, NoisePct: 1}, + + // HR tools + {Name: "Workday", DeptCoverage: map[string]int{"Human Resources": 97}, NoisePct: 1}, + {Name: "Greenhouse", DeptCoverage: map[string]int{"Human Resources": 93}, NoisePct: 2}, + {Name: "BambooHR", DeptCoverage: map[string]int{"Human Resources": 88}, NoisePct: 1}, + {Name: "Culture Amp", DeptCoverage: map[string]int{"Human Resources": 73}, NoisePct: 1}, + + // Product tools + {Name: "Amplitude", DeptCoverage: map[string]int{"Product": 89}, NoisePct: 2}, + {Name: "FullStory", DeptCoverage: map[string]int{"Product": 78}, NoisePct: 1}, + {Name: "Notion", DeptCoverage: map[string]int{"Product": 87, "Engineering": 72}, NoisePct: 4}, +} + +// Realistic first names for user generation. +var firstNames = []string{ + "James", "Mary", "Robert", "Patricia", "John", "Jennifer", "Michael", "Linda", + "David", "Elizabeth", "William", "Barbara", "Richard", "Susan", "Joseph", "Jessica", + "Thomas", "Sarah", "Christopher", "Karen", "Charles", "Lisa", "Daniel", "Nancy", + "Matthew", "Betty", "Anthony", "Dorothy", "Mark", "Sandra", "Donald", "Ashley", + "Steven", "Kimberly", "Paul", "Emily", "Andrew", "Donna", "Joshua", "Michelle", + "Kenneth", "Carol", "Kevin", "Amanda", "Brian", "Melissa", "George", "Deborah", + "Timothy", "Stephanie", "Ronald", "Rebecca", "Jason", "Sharon", "Edward", "Laura", + "Jeffrey", "Cynthia", "Ryan", "Kathleen", "Jacob", "Amy", "Gary", "Angela", + "Nicholas", "Shirley", "Eric", "Brenda", "Jonathan", "Emma", "Stephen", "Anna", + "Larry", "Pamela", "Justin", "Nicole", "Scott", "Samantha", "Brandon", "Katherine", + "Benjamin", "Christine", "Samuel", "Helen", "Raymond", "Debra", "Gregory", "Rachel", + "Frank", "Carolyn", "Alexander", "Janet", "Patrick", "Catherine", "Jack", "Maria", +} + +// Realistic last names for user generation. +var lastNames = []string{ + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", + "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", + "Thomas", "Taylor", "Moore", "Jackson", "Martin", "Lee", "Perez", "Thompson", + "White", "Harris", "Sanchez", "Clark", "Ramirez", "Lewis", "Robinson", "Walker", + "Young", "Allen", "King", "Wright", "Scott", "Torres", "Nguyen", "Hill", + "Flores", "Green", "Adams", "Nelson", "Baker", "Hall", "Rivera", "Campbell", + "Mitchell", "Carter", "Roberts", "Gomez", "Phillips", "Evans", "Turner", "Diaz", + "Parker", "Cruz", "Edwards", "Collins", "Reyes", "Stewart", "Morris", "Morales", + "Murphy", "Cook", "Rogers", "Gutierrez", "Ortiz", "Morgan", "Cooper", "Peterson", + "Bailey", "Reed", "Kelly", "Howard", "Ramos", "Kim", "Cox", "Ward", + "Richardson", "Watson", "Brooks", "Chavez", "Wood", "James", "Bennett", "Gray", + "Mendoza", "Ruiz", "Hughes", "Price", "Alvarez", "Castillo", "Sanders", "Patel", +} + +// getDepartment returns the department and job title for a given user index. +func getDepartment(userIdx, totalUsers int) (string, string) { + // Map user index to a percentage bucket (0-99) + pct := (userIdx * 100) / totalUsers + for _, dept := range departments { + if pct < dept.Weight { + titleIdx := userIdx % len(dept.JobTitles) + return dept.Name, dept.JobTitles[titleIdx] + } + } + // Fallback to last department + last := departments[len(departments)-1] + return last.Name, last.JobTitles[userIdx%len(last.JobTitles)] +} + +// isManagerTitle returns true if the job title is a management role. +func isManagerTitle(title string) bool { + switch title { + case "Engineering Manager", "Sales Manager", "Marketing Manager", + "HR Manager", "Controller", "Senior Product Manager": + return true + } + return false +} + +// getManagerEmail returns the email of the first manager in the same department. +// For managers themselves, returns the email of the next manager (or empty if they're the only one). +// totalUsers must match the value used during generation. +func getManagerEmail(userIdx, totalUsers int) string { + dept, title := getDepartment(userIdx, totalUsers) + isManager := isManagerTitle(title) + + // Scan all users to find the first manager in the same department. + for i := 0; i < totalUsers; i++ { + if i == userIdx { + continue + } + d, t := getDepartment(i, totalUsers) + if d != dept { + continue + } + if !isManagerTitle(t) { + continue + } + // Non-managers report to the first manager found. + // Managers report to a different manager (not themselves). + if !isManager || i != userIdx { + first, last := getUserName(i) + return fmt.Sprintf("%s.%s.%d@example.com", first, last, i) + } + } + return "" +} + +// getUserName returns a realistic first/last name for a user index. +func getUserName(userIdx int) (string, string) { + first := firstNames[userIdx%len(firstNames)] + // Offset last name index to avoid repetitive "James Smith, Mary Johnson" patterns + last := lastNames[(userIdx*7+userIdx/len(firstNames))%len(lastNames)] + return first, last +} + +// shouldAssign is a deterministic hash-like function that decides group membership. +// Returns true if the user at userIdx should be a member of the group at groupIdx +// given the coverage percentage. +func shouldAssign(userIdx, groupIdx, coveragePct int) bool { + if coveragePct >= 100 { + return true + } + if coveragePct <= 0 { + return false + } + // Knuth multiplicative hash for good distribution + h := uint32(userIdx)*2654435761 + uint32(groupIdx)*2246822519 + // Mix bits for better uniformity + h ^= h >> 16 + h *= 0x45d9f3b + h ^= h >> 16 + return int(h%100) < coveragePct +} + type generator struct { config *config.Demo currentUser int @@ -42,68 +258,94 @@ type generator struct { currentRole int currentScopedRole int currentProject int + groupsDone bool // true after all app groups + Everyone group are emitted } func userId(i int) string { return fmt.Sprintf("user-%07d", i) } +func groupId(i int) string { + return fmt.Sprintf("group-%07d", i) +} + +func (g *generator) totalAppGroups() int { + return len(appGroups) +} + func (g *generator) Next() (*dbResource, bool) { - if g.currentGroup == g.config.Groups { - // Make the Everyone group. - groupAdmins := []string{userId(0)} - groupMembers := []string{} + // Phase 1: Generate app-based groups + if !g.groupsDone { + if g.currentGroup < g.totalAppGroups() { + app := appGroups[g.currentGroup] + members := []string{} + admins := []string{} + + for i := 0; i < g.config.Users; i++ { + dept, _ := getDepartment(i, g.config.Users) + coverage, inTargetDept := app.DeptCoverage[dept] + + assign := false + if inTargetDept { + assign = shouldAssign(i, g.currentGroup, coverage) + } else if app.NoisePct > 0 { + // Cross-department noise access + assign = shouldAssign(i, g.currentGroup+1000, app.NoisePct) + } + + if assign { + members = append(members, userId(i)) + // Make ~2% of members admins + if shouldAssign(i, g.currentGroup, 2) { + admins = append(admins, userId(i)) + } + } + } + + db := &dbResource{ + Group: &Group{ + Id: groupId(g.currentGroup), + Name: app.Name, + Admins: admins, + Members: members, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } + g.currentGroup++ + return db, true + } + + // Everyone group + groupMembers := make([]string, 0, g.config.Users) for i := 0; i < g.config.Users; i++ { groupMembers = append(groupMembers, userId(i)) } - db := &dbResource{ Group: &Group{ Id: "group-everyone", Name: "Everyone", - Admins: groupAdmins, - Members: groupMembers, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }, - } - g.currentGroup++ - return db, true - } - if g.currentGroup < g.config.Groups { - groupAdmins := []string{} - groupMembers := []string{} - usersPerGroup := 20 // Add 5% of users to each group - for i := 0; i < g.config.Users; i++ { - if i%usersPerGroup == 0 { - groupMembers = append(groupMembers, userId(i)) - if i%(usersPerGroup*10) == 0 { - groupAdmins = append(groupAdmins, userId(i)) - } - } - } - db := &dbResource{ - Group: &Group{ - Id: fmt.Sprintf("group-%07d", g.currentGroup), - Name: fmt.Sprintf("Group %07d", g.currentGroup), - Admins: groupAdmins, + Admins: []string{userId(0)}, Members: groupMembers, CreatedAt: time.Now(), UpdatedAt: time.Now(), }, } - g.currentGroup++ + g.groupsDone = true return db, true } + + // Phase 2: Projects (use configured count) if g.currentProject < g.config.Projects { + totalGroups := g.totalAppGroups() db := &dbResource{ Project: &Project{ Id: fmt.Sprintf("project-%07d", g.currentProject), Name: fmt.Sprintf("Project %07d", g.currentProject), Owner: userId(g.currentProject % g.config.Users), GroupAssignments: []string{ - fmt.Sprintf("group-%07d", g.currentProject%g.config.Groups), - fmt.Sprintf("group-%07d", (g.currentProject*10)%g.config.Groups), + groupId(g.currentProject % totalGroups), + groupId((g.currentProject * 10) % totalGroups), }, CreatedAt: time.Now(), UpdatedAt: time.Now(), @@ -112,16 +354,19 @@ func (g *generator) Next() (*dbResource, bool) { g.currentProject++ return db, true } + + // Phase 3: Roles (use configured count) if g.currentRole < g.config.Roles { + totalGroups := g.totalAppGroups() directAssignments := []string{} if g.config.Users > 0 { directAssignments = append(directAssignments, userId(g.currentRole%g.config.Users)) directAssignments = append(directAssignments, userId((g.currentRole*10)%g.config.Users)) } groupAssignments := []string{} - if g.config.Groups > 5 { - groupAssignments = append(groupAssignments, fmt.Sprintf("group-%07d", g.currentRole%g.config.Groups)) - groupAssignments = append(groupAssignments, fmt.Sprintf("group-%07d", (g.currentRole*10)%g.config.Groups)) + if totalGroups > 5 { + groupAssignments = append(groupAssignments, groupId(g.currentRole%totalGroups)) + groupAssignments = append(groupAssignments, groupId((g.currentRole*10)%totalGroups)) } db := &dbResource{ Role: &Role{ @@ -136,6 +381,8 @@ func (g *generator) Next() (*dbResource, bool) { g.currentRole++ return db, true } + + // Phase 4: Scoped roles (use configured count) if g.currentScopedRole < g.config.ScopedRoles { userAssignments := []string{} if g.config.Users > 0 { @@ -158,26 +405,41 @@ func (g *generator) Next() (*dbResource, bool) { g.currentScopedRole++ return db, true } + + // Phase 5: Users with department, job title, and manager if g.currentUser < g.config.Users { - userFullName := fmt.Sprintf("User %07d", g.currentUser) - userEmail := fmt.Sprintf("user-%07d@example.com", g.currentUser) + first, last := getUserName(g.currentUser) + fullName := fmt.Sprintf("%s %s", first, last) + email := fmt.Sprintf("%s.%s.%d@example.com", first, last, g.currentUser) + dept, jobTitle := getDepartment(g.currentUser, g.config.Users) + + attrs := map[string]string{ + "full_name": fullName, + "email": email, + "department": dept, + "job_title": jobTitle, + } + + if managerEmail := getManagerEmail(g.currentUser, g.config.Users); managerEmail != "" { + attrs["manager_email"] = managerEmail + } + db := &dbResource{ User: &User{ Id: userId(g.currentUser), - Name: userFullName, - Email: userEmail, - Enabled: true, // Default to enabled + Name: fullName, + Email: email, + Enabled: true, + Attrs: attrs, CreatedAt: time.Now(), UpdatedAt: time.Now(), - Attrs: map[string]string{ - "full_name": userFullName, - "email": userEmail, - }, }, } g.currentUser++ return db, true } + + // Phase 6: Passwords if g.currentPassword < g.config.Users { db := &dbResource{ Password: &Password{ diff --git a/pkg/config/config.go b/pkg/config/config.go index d75cfad8..18efbe89 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -27,7 +27,7 @@ var ( ) UserCountField = field.IntField("users", field.WithDescription("Number of users to create."), - field.WithDefaultValue(10), + field.WithDefaultValue(200), field.WithInt(func(r *field.IntRuler) { r.Gt(0) }), ) InitDB = field.BoolField("init-db", field.WithDescription("Whether to initialize the database."), field.WithDefaultValue(false)) From 9861011ecb6b7cc3027274a114cf187597e2b287 Mon Sep 17 00:00:00 2001 From: Muhammad Kumail Date: Wed, 25 Feb 2026 23:13:31 +0000 Subject: [PATCH 2/3] add: multi national org --- pkg/client/data.go | 977 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 806 insertions(+), 171 deletions(-) diff --git a/pkg/client/data.go b/pkg/client/data.go index 657be210..0a766ffd 100644 --- a/pkg/client/data.go +++ b/pkg/client/data.go @@ -34,206 +34,740 @@ func (r *dbResource) String() string { return "Unknown" } -// Department definitions with proportional weights. +// --- Organization hierarchy --- + +const numCxO = 7 +const numServiceAccounts = 4 +const numSharedAccounts = 2 +const numSpecialAccounts = numServiceAccounts + numSharedAccounts + +type orgPosition struct { + Title string + Department string + Level string + Region string + EmploymentType string // Full-time, Contractor, Service Account, Shared Account + Enabled bool + ManagerIdx int // -1 = no manager +} + type departmentDef struct { - Name string - Weight int // Cumulative percentage boundary (0-100) - JobTitles []string + Name string + Weight int // cumulative percentage boundary (0-100) + CxoIdx int // index into cSuitePositions + Titles map[string][]string +} + +type levelDef struct { + Name string + Weight int // cumulative percentage boundary (0-100) +} + +var cSuitePositions = [numCxO]struct{ Title string }{ + {"Chief Executive Officer"}, + {"Chief Technology Officer"}, + {"Chief Financial Officer"}, + {"Chief Operating Officer"}, + {"Chief Marketing Officer"}, + {"Chief Human Resources Officer"}, + {"Chief Revenue Officer"}, } -// departments defines the org structure. Weights are cumulative boundaries: -// Engineering=0-34, Sales=35-54, Marketing=55-66, Finance=67-76, HR=77-86, Product=87-99. +// departments defines the org. Weights are cumulative boundaries. +// Engineering=0-25, Sales=26-42, Customer Support=43-54, Marketing=55-64, +// Product=65-72, Finance=73-80, HR=81-87, Workplace & IT=88-92, Legal=93-99. var departments = []departmentDef{ - {Name: "Engineering", Weight: 35, JobTitles: []string{ - "Software Engineer", "Senior Software Engineer", "Staff Engineer", - "Engineering Manager", "QA Engineer", "DevOps Engineer", "Frontend Engineer", + {Name: "Engineering", Weight: 26, CxoIdx: 1, Titles: map[string][]string{ + "VP": {"VP of Engineering"}, + "Director": {"Director of Platform Engineering", "Director of Infrastructure", "Director of Security Engineering", "Director of QA"}, + "Manager": {"Engineering Manager - Backend", "Engineering Manager - Frontend", "Engineering Manager - DevOps", "Engineering Manager - SRE", "Engineering Manager - Mobile", "Engineering Manager - Data"}, + "Senior IC": {"Staff Engineer", "Senior Software Engineer", "Senior DevOps Engineer", "Senior Security Engineer", "Senior QA Engineer", "Senior Data Engineer"}, + "IC": {"Software Engineer", "Frontend Engineer", "Backend Engineer", "DevOps Engineer", "QA Engineer", "Security Engineer", "Data Engineer"}, + "Contractor": {"Contract Software Engineer", "Contract QA Engineer"}, }}, - {Name: "Sales", Weight: 55, JobTitles: []string{ - "Account Executive", "Sales Development Rep", "Sales Manager", - "Solutions Engineer", "Sales Operations Analyst", + {Name: "Sales", Weight: 43, CxoIdx: 6, Titles: map[string][]string{ + "VP": {"VP of Sales"}, + "Director": {"Director of Enterprise Sales", "Director of SMB Sales", "Director of Sales Operations"}, + "Manager": {"Sales Manager - Enterprise", "Sales Manager - SMB", "Sales Manager - APAC", "Sales Manager - EMEA"}, + "Senior IC": {"Senior Account Executive", "Senior Solutions Engineer", "Senior Sales Operations Analyst"}, + "IC": {"Account Executive", "Sales Development Rep", "Solutions Engineer", "Sales Operations Analyst", "Business Development Rep"}, + "Contractor": {"Contract Sales Rep"}, }}, - {Name: "Marketing", Weight: 67, JobTitles: []string{ - "Marketing Manager", "Content Writer", "Growth Analyst", - "Brand Designer", "SEO Specialist", + {Name: "Customer Support", Weight: 55, CxoIdx: 6, Titles: map[string][]string{ + "VP": {"VP of Customer Support"}, + "Director": {"Director of Support Operations", "Director of Technical Support"}, + "Manager": {"CS Manager - Americas", "CS Manager - EMEA", "CS Manager - APAC", "CS Manager - Technical"}, + "Senior IC": {"Senior Customer Success Manager", "Senior Support Engineer"}, + "IC": {"Customer Support Rep", "Customer Success Manager", "Support Engineer", "Support Analyst"}, + "Contractor": {"Contract Support Rep"}, }}, - {Name: "Finance", Weight: 77, JobTitles: []string{ - "Financial Analyst", "Accountant", "Controller", "FP&A Analyst", + {Name: "Marketing", Weight: 65, CxoIdx: 4, Titles: map[string][]string{ + "VP": {"VP of Marketing"}, + "Director": {"Director of Content & Brand", "Director of Growth Marketing", "Director of Events & Communications"}, + "Manager": {"Marketing Manager - Content", "Marketing Manager - Digital", "Marketing Manager - Events"}, + "Senior IC": {"Senior Content Strategist", "Senior Growth Analyst", "Senior Brand Designer"}, + "IC": {"Content Writer", "SEO Specialist", "Growth Analyst", "Brand Designer", "Event Coordinator", "Social Media Manager"}, + "Contractor": {"Contract Designer", "Contract Copywriter"}, }}, - {Name: "Human Resources", Weight: 87, JobTitles: []string{ - "HR Business Partner", "Recruiter", "Compensation Analyst", - "HR Manager", "People Operations", + {Name: "Product", Weight: 73, CxoIdx: 1, Titles: map[string][]string{ + "VP": {"VP of Product"}, + "Director": {"Director of Product Management", "Director of Design"}, + "Manager": {"Lead Product Manager", "Design Manager"}, + "Senior IC": {"Staff Product Manager", "Senior Product Designer", "Senior UX Researcher"}, + "IC": {"Product Manager", "Product Designer", "UX Researcher", "Technical Writer", "Data Analyst"}, + "Contractor": {"Contract Product Designer"}, }}, - {Name: "Product", Weight: 100, JobTitles: []string{ - "Product Manager", "Senior Product Manager", "Product Designer", - "UX Researcher", "Technical Writer", + {Name: "Finance", Weight: 81, CxoIdx: 2, Titles: map[string][]string{ + "VP": {"VP of Finance"}, + "Director": {"Director of Accounting", "Director of FP&A"}, + "Manager": {"Finance Manager - AP/AR", "Finance Manager - Planning"}, + "Senior IC": {"Senior Financial Analyst", "Senior Accountant"}, + "IC": {"Financial Analyst", "Accountant", "FP&A Analyst", "Billing Specialist", "Accounts Payable Specialist"}, + "Contractor": {"Contract Accountant"}, + }}, + {Name: "Human Resources", Weight: 88, CxoIdx: 5, Titles: map[string][]string{ + "VP": {"VP of Human Resources"}, + "Director": {"Director of Talent Acquisition", "Director of HR Business Partners", "Director of Compensation & Benefits"}, + "Manager": {"HR Manager", "Recruiting Manager", "Benefits Manager"}, + "Senior IC": {"Senior HR Business Partner", "Senior Recruiter", "Senior Compensation Analyst"}, + "IC": {"HR Business Partner", "Recruiter", "Compensation Analyst", "People Operations Coordinator", "HR Coordinator"}, + "Contractor": {"Contract Recruiter"}, + }}, + {Name: "Workplace & IT", Weight: 93, CxoIdx: 3, Titles: map[string][]string{ + "VP": {"VP of Workplace & IT"}, + "Director": {"Director of IT Operations", "Director of Facilities"}, + "Manager": {"IT Manager", "Facilities Manager"}, + "Senior IC": {"Senior Systems Administrator", "Senior IT Engineer"}, + "IC": {"IT Support Specialist", "Systems Administrator", "Office Coordinator", "Facilities Coordinator", "Help Desk Analyst"}, + "Contractor": {"Contract IT Support"}, + }}, + {Name: "Legal", Weight: 100, CxoIdx: 2, Titles: map[string][]string{ + "VP": {"VP & General Counsel"}, + "Director": {"Director of Legal", "Director of Compliance"}, + "Manager": {"Legal Operations Manager", "Compliance Manager"}, + "Senior IC": {"Senior Corporate Counsel", "Senior Compliance Analyst"}, + "IC": {"Paralegal", "Compliance Analyst", "Legal Coordinator", "Contract Administrator"}, + "Contractor": {"Contract Paralegal"}, }}, } -// appDef defines a SaaS application group with department-correlated access. +// VP=0-3%, Director=4-12%, Manager=13-26%, Senior IC=27-51%, IC=52-91%, Contractor=92-99%. +var levelDistribution = []levelDef{ + {"VP", 4}, + {"Director", 13}, + {"Manager", 27}, + {"Senior IC", 52}, + {"IC", 92}, + {"Contractor", 100}, +} + +var regionDistribution = []struct { + Name string + Weight int +}{ + {"Americas", 55}, + {"EMEA", 80}, + {"APAC", 100}, +} + +var levelOrder = map[string]int{ + "C-Suite": 6, "VP": 5, "Director": 4, "Manager": 3, + "Senior IC": 2, "IC": 1, "Contractor": 0, +} + +var parentLevel = map[string]string{ + "VP": "C-Suite", "Director": "VP", "Manager": "Director", + "Senior IC": "Manager", "IC": "Manager", "Contractor": "Manager", +} + +var serviceAccountDefs = [numServiceAccounts]struct { + Name, Email, Dept string +}{ + {"CI/CD Pipeline Bot", "cicd-bot@service.example.com", "Engineering"}, + {"Monitoring Service", "monitoring-svc@service.example.com", "Engineering"}, + {"Integration Service", "integration-svc@service.example.com", "Workplace & IT"}, + {"Analytics Pipeline", "analytics-pipeline@service.example.com", "Product"}, +} + +var sharedAccountDefs = [numSharedAccounts]struct { + Name, Email, Dept string +}{ + {"Sales Demo Environment", "sales-demo@shared.example.com", "Sales"}, + {"QA Test Environment", "qa-test@shared.example.com", "Engineering"}, +} + +// --- SaaS application groups --- + type appDef struct { Name string - DeptCoverage map[string]int // department name -> coverage percentage (0-100) - NoisePct int // percentage of non-target dept users who also get access + DeptCoverage map[string]int + NoisePct int + MinLevel string // minimum seniority level (empty = any) + RegionOnly string // restrict to region (empty = all regions) } -// appGroups defines the SaaS applications and their department access patterns. -// Coverage percentages control what fraction of users in each department get the app. -// NoisePct controls cross-department "outlier" access. -var appGroups = []appDef{ - // Universal apps - everyone gets these - {Name: "Google Workspace", DeptCoverage: map[string]int{ - "Engineering": 100, "Sales": 100, "Marketing": 100, - "Finance": 100, "Human Resources": 100, "Product": 100, - }}, - {Name: "Slack", DeptCoverage: map[string]int{ - "Engineering": 100, "Sales": 100, "Marketing": 100, - "Finance": 100, "Human Resources": 100, "Product": 100, - }}, - {Name: "Okta", DeptCoverage: map[string]int{ - "Engineering": 100, "Sales": 100, "Marketing": 100, - "Finance": 100, "Human Resources": 100, "Product": 100, - }}, - {Name: "1Password", DeptCoverage: map[string]int{ - "Engineering": 97, "Sales": 95, "Marketing": 95, - "Finance": 96, "Human Resources": 95, "Product": 96, - }}, +func allDepts(coverage int) map[string]int { + return map[string]int{ + "Engineering": coverage, "Sales": coverage, "Marketing": coverage, + "Finance": coverage, "Human Resources": coverage, "Product": coverage, + "Customer Support": coverage, "Workplace & IT": coverage, + "Legal": coverage, "Executive": coverage, + } +} - // Engineering tools - {Name: "GitHub", DeptCoverage: map[string]int{"Engineering": 96, "Product": 35}, NoisePct: 3}, - {Name: "Jira", DeptCoverage: map[string]int{"Engineering": 93, "Product": 88, "Marketing": 25}, NoisePct: 4}, - {Name: "AWS Console", DeptCoverage: map[string]int{"Engineering": 88}, NoisePct: 2}, +var appGroups = []appDef{ + // ================================================================== + // Google Workspace (parent + children) + // ================================================================== + {Name: "Google Workspace", DeptCoverage: allDepts(100)}, + {Name: "Google Workspace - Admin Console", DeptCoverage: map[string]int{"Workplace & IT": 80, "Engineering": 10}, NoisePct: 1}, + {Name: "Google Workspace - Drive Manager", DeptCoverage: map[string]int{ + "Workplace & IT": 70, "Legal": 40, "Human Resources": 35, "Finance": 30, + }, NoisePct: 2}, + {Name: "Google Workspace - Vault", DeptCoverage: map[string]int{"Legal": 85, "Human Resources": 40, "Finance": 25}, NoisePct: 1}, + {Name: "Google Workspace - Groups Admin", DeptCoverage: map[string]int{"Workplace & IT": 65, "Human Resources": 20}, NoisePct: 1}, + + // ================================================================== + // Slack (parent + children) + // ================================================================== + {Name: "Slack", DeptCoverage: allDepts(100)}, + {Name: "Slack - Workspace Admin", DeptCoverage: map[string]int{"Workplace & IT": 70, "Engineering": 5}, NoisePct: 1}, + {Name: "Slack - Channel Management", DeptCoverage: map[string]int{"Workplace & IT": 50, "Human Resources": 20, "Marketing": 15}, NoisePct: 1}, + {Name: "Slack - App Installation", DeptCoverage: map[string]int{"Workplace & IT": 45, "Engineering": 10}, NoisePct: 1}, + {Name: "Slack - Analytics", DeptCoverage: map[string]int{"Workplace & IT": 55, "Human Resources": 15}, NoisePct: 1}, + + // ================================================================== + // Okta (parent + children) + // ================================================================== + {Name: "Okta", DeptCoverage: allDepts(100)}, + {Name: "Okta - Admin Console", DeptCoverage: map[string]int{"Workplace & IT": 85, "Engineering": 15}, NoisePct: 1}, + {Name: "Okta - App Management", DeptCoverage: map[string]int{"Workplace & IT": 70}, NoisePct: 1}, + {Name: "Okta - User Lifecycle", DeptCoverage: map[string]int{"Workplace & IT": 60, "Human Resources": 30}, NoisePct: 1}, + {Name: "Okta - MFA Policy Admin", DeptCoverage: map[string]int{"Workplace & IT": 55, "Engineering": 8}, NoisePct: 1}, + + // ================================================================== + // Other universal apps (no children) + // ================================================================== + {Name: "Zoom", DeptCoverage: allDepts(100)}, + {Name: "1Password", DeptCoverage: allDepts(97)}, + {Name: "Confluence", DeptCoverage: map[string]int{ + "Engineering": 95, "Sales": 70, "Marketing": 75, "Finance": 65, + "Human Resources": 70, "Product": 93, "Customer Support": 60, + "Workplace & IT": 80, "Legal": 55, "Executive": 85, + }, NoisePct: 3}, + + // ================================================================== + // GitHub (parent + children) + // ================================================================== + {Name: "GitHub", DeptCoverage: map[string]int{"Engineering": 96, "Product": 35, "Executive": 15}, NoisePct: 3}, + {Name: "GitHub - Org Owner", DeptCoverage: map[string]int{"Engineering": 5}}, + {Name: "GitHub - Actions Admin", DeptCoverage: map[string]int{"Engineering": 25}, NoisePct: 1}, + {Name: "GitHub - Security Alerts", DeptCoverage: map[string]int{"Engineering": 30, "Workplace & IT": 20}, NoisePct: 1}, + {Name: "GitHub - Code Scanning", DeptCoverage: map[string]int{"Engineering": 20}, NoisePct: 1}, + {Name: "GitHub - Copilot", DeptCoverage: map[string]int{"Engineering": 70, "Product": 15}, NoisePct: 2}, + {Name: "GitHub - Enterprise Admin", DeptCoverage: map[string]int{"Engineering": 3, "Workplace & IT": 10}}, + + // ================================================================== + // Jira (parent + children) + // ================================================================== + {Name: "Jira", DeptCoverage: map[string]int{"Engineering": 93, "Product": 88, "Marketing": 25, "Customer Support": 30}, NoisePct: 4}, + {Name: "Jira - Admin", DeptCoverage: map[string]int{"Engineering": 12, "Product": 8, "Workplace & IT": 15}, NoisePct: 1}, + {Name: "Jira - Board Management", DeptCoverage: map[string]int{"Engineering": 40, "Product": 50}, NoisePct: 1}, + {Name: "Jira - Automation Rules", DeptCoverage: map[string]int{"Engineering": 18, "Product": 12}, NoisePct: 1}, + {Name: "Jira - Service Management", DeptCoverage: map[string]int{"Customer Support": 75, "Workplace & IT": 60, "Engineering": 20}, NoisePct: 2}, + + // ================================================================== + // AWS (parent + children) + // ================================================================== + {Name: "AWS Console", DeptCoverage: map[string]int{"Engineering": 88, "Executive": 10}, NoisePct: 2}, + {Name: "AWS - Production Deploy", DeptCoverage: map[string]int{"Engineering": 45}}, + {Name: "AWS - Production DB Admin", DeptCoverage: map[string]int{"Engineering": 35}}, + {Name: "AWS - S3 Storage", DeptCoverage: map[string]int{"Engineering": 60, "Product": 10}, NoisePct: 1}, + {Name: "AWS - IAM Admin", DeptCoverage: map[string]int{"Engineering": 10, "Workplace & IT": 30}}, + {Name: "AWS - CloudWatch", DeptCoverage: map[string]int{"Engineering": 55}, NoisePct: 1}, + {Name: "AWS - Cost Explorer", DeptCoverage: map[string]int{"Finance": 50, "Engineering": 15, "Executive": 20}, NoisePct: 1}, + {Name: "AWS - Lambda", DeptCoverage: map[string]int{"Engineering": 50}, NoisePct: 1}, + {Name: "AWS - EKS Admin", DeptCoverage: map[string]int{"Engineering": 25}}, + + // ================================================================== + // Datadog (parent + children) + // ================================================================== {Name: "Datadog", DeptCoverage: map[string]int{"Engineering": 83}, NoisePct: 1}, + {Name: "Datadog - Admin", DeptCoverage: map[string]int{"Engineering": 12}, NoisePct: 1}, + {Name: "Datadog - Dashboard Management", DeptCoverage: map[string]int{"Engineering": 40, "Product": 10}, NoisePct: 1}, + {Name: "Datadog - Monitors & Alerts", DeptCoverage: map[string]int{"Engineering": 55}, NoisePct: 1}, + + // Other engineering tools (no children) {Name: "PagerDuty", DeptCoverage: map[string]int{"Engineering": 74}, NoisePct: 1}, {Name: "CircleCI", DeptCoverage: map[string]int{"Engineering": 68}, NoisePct: 1}, - - // Sales tools - {Name: "Salesforce", DeptCoverage: map[string]int{"Sales": 96}, NoisePct: 2}, + {Name: "Docker Hub", DeptCoverage: map[string]int{"Engineering": 72}, NoisePct: 1}, + {Name: "Terraform Cloud", DeptCoverage: map[string]int{"Engineering": 55}, NoisePct: 1}, + + // ================================================================== + // Salesforce (parent + children) + // ================================================================== + {Name: "Salesforce", DeptCoverage: map[string]int{"Sales": 96, "Customer Support": 40, "Executive": 20}, NoisePct: 2}, + {Name: "Salesforce - Admin", DeptCoverage: map[string]int{"Sales": 8, "Workplace & IT": 15}}, + {Name: "Salesforce - Sales Cloud", DeptCoverage: map[string]int{"Sales": 85}, NoisePct: 1}, + {Name: "Salesforce - Service Cloud", DeptCoverage: map[string]int{"Customer Support": 80, "Sales": 25}, NoisePct: 1}, + {Name: "Salesforce - Reports & Dashboards", DeptCoverage: map[string]int{"Sales": 50, "Finance": 30, "Executive": 40}, NoisePct: 2}, + {Name: "Salesforce - CPQ", DeptCoverage: map[string]int{"Sales": 40}, NoisePct: 1}, + {Name: "Salesforce - Marketing Cloud", DeptCoverage: map[string]int{"Marketing": 60, "Sales": 15}, NoisePct: 1}, + + // ================================================================== + // HubSpot (parent + children) + // ================================================================== {Name: "HubSpot", DeptCoverage: map[string]int{"Sales": 91, "Marketing": 88}, NoisePct: 3}, + {Name: "HubSpot - Admin", DeptCoverage: map[string]int{"Sales": 8, "Marketing": 10, "Workplace & IT": 12}}, + {Name: "HubSpot - Marketing Hub", DeptCoverage: map[string]int{"Marketing": 80}, NoisePct: 1}, + {Name: "HubSpot - Sales Hub", DeptCoverage: map[string]int{"Sales": 82}, NoisePct: 1}, + {Name: "HubSpot - CMS Hub", DeptCoverage: map[string]int{"Marketing": 45, "Product": 10}, NoisePct: 1}, + + // Other sales tools (no children) {Name: "Gong", DeptCoverage: map[string]int{"Sales": 87}, NoisePct: 2}, {Name: "Outreach", DeptCoverage: map[string]int{"Sales": 78}, NoisePct: 1}, {Name: "LinkedIn Sales Navigator", DeptCoverage: map[string]int{"Sales": 73}, NoisePct: 1}, + {Name: "Clari", DeptCoverage: map[string]int{"Sales": 65}, NoisePct: 1}, + + // ================================================================== + // Zendesk (parent + children) + // ================================================================== + {Name: "Zendesk", DeptCoverage: map[string]int{"Customer Support": 95, "Sales": 20, "Product": 15}, NoisePct: 2}, + {Name: "Zendesk - Admin", DeptCoverage: map[string]int{"Customer Support": 10, "Workplace & IT": 15}}, + {Name: "Zendesk - Ticket Automation", DeptCoverage: map[string]int{"Customer Support": 45, "Engineering": 8}, NoisePct: 1}, + {Name: "Zendesk - Knowledge Base", DeptCoverage: map[string]int{"Customer Support": 60, "Product": 20, "Marketing": 10}, NoisePct: 2}, + {Name: "Zendesk - Explore Analytics", DeptCoverage: map[string]int{"Customer Support": 35, "Product": 15}, NoisePct: 1}, + + // Other CS tools + {Name: "Intercom", DeptCoverage: map[string]int{"Customer Support": 88, "Product": 25, "Marketing": 15}, NoisePct: 2}, + + // ================================================================== + // Figma (parent + children) + // ================================================================== + {Name: "Figma", DeptCoverage: map[string]int{"Marketing": 91, "Product": 89, "Engineering": 22}, NoisePct: 3}, + {Name: "Figma - Admin", DeptCoverage: map[string]int{"Product": 8, "Marketing": 5, "Workplace & IT": 10}}, + {Name: "Figma - Organization Libraries", DeptCoverage: map[string]int{"Product": 60, "Marketing": 55, "Engineering": 10}, NoisePct: 1}, + {Name: "Figma - Dev Mode", DeptCoverage: map[string]int{"Engineering": 18, "Product": 30}, NoisePct: 1}, - // Marketing tools + // Other marketing tools (no children) {Name: "Google Analytics", DeptCoverage: map[string]int{"Marketing": 90, "Product": 82}, NoisePct: 3}, - {Name: "Figma", DeptCoverage: map[string]int{"Marketing": 91, "Product": 89, "Engineering": 22}, NoisePct: 3}, {Name: "Canva", DeptCoverage: map[string]int{"Marketing": 83}, NoisePct: 2}, {Name: "Mailchimp", DeptCoverage: map[string]int{"Marketing": 78}, NoisePct: 1}, - - // Finance tools + {Name: "Marketo", DeptCoverage: map[string]int{"Marketing": 72}, NoisePct: 1}, + {Name: "Hootsuite", DeptCoverage: map[string]int{"Marketing": 65}, NoisePct: 1}, + + // ---- Product tools (no children) ---- + {Name: "Amplitude", DeptCoverage: map[string]int{"Product": 89, "Engineering": 30}, NoisePct: 2}, + {Name: "FullStory", DeptCoverage: map[string]int{"Product": 78, "Customer Support": 20}, NoisePct: 1}, + {Name: "Notion", DeptCoverage: map[string]int{"Product": 87, "Engineering": 72, "Marketing": 40}, NoisePct: 4}, + {Name: "Miro", DeptCoverage: map[string]int{"Product": 80, "Engineering": 45, "Marketing": 35}, NoisePct: 3}, + {Name: "Productboard", DeptCoverage: map[string]int{"Product": 75, "Customer Support": 15}, NoisePct: 1}, + + // ================================================================== + // NetSuite (parent + children) + // ================================================================== {Name: "NetSuite", DeptCoverage: map[string]int{"Finance": 96}, NoisePct: 1}, + {Name: "NetSuite - Admin", DeptCoverage: map[string]int{"Finance": 15}}, + {Name: "NetSuite - Financial Reports", DeptCoverage: map[string]int{"Finance": 80, "Executive": 30}, NoisePct: 1}, + {Name: "NetSuite - Payment Creation", DeptCoverage: map[string]int{"Finance": 70}}, + {Name: "NetSuite - Payment Approval", DeptCoverage: map[string]int{"Finance": 65}}, + {Name: "NetSuite - Inventory Management", DeptCoverage: map[string]int{"Finance": 40}, NoisePct: 1}, + {Name: "NetSuite - Vendor Portal", DeptCoverage: map[string]int{"Finance": 50}, NoisePct: 1}, + + // Other finance tools (no children) {Name: "Expensify", DeptCoverage: map[string]int{"Finance": 92}, NoisePct: 2}, {Name: "Stripe Dashboard", DeptCoverage: map[string]int{"Finance": 76, "Engineering": 28}, NoisePct: 2}, {Name: "QuickBooks", DeptCoverage: map[string]int{"Finance": 69}, NoisePct: 1}, + {Name: "Coupa", DeptCoverage: map[string]int{"Finance": 60}, NoisePct: 1}, - // HR tools + // ================================================================== + // Workday (parent + children) + // ================================================================== {Name: "Workday", DeptCoverage: map[string]int{"Human Resources": 97}, NoisePct: 1}, + {Name: "Workday - Admin", DeptCoverage: map[string]int{"Human Resources": 40}}, + {Name: "Workday - Compensation Admin", DeptCoverage: map[string]int{"Human Resources": 30, "Finance": 15}}, + {Name: "Workday - Benefits Admin", DeptCoverage: map[string]int{"Human Resources": 35}}, + {Name: "Workday - Reporting", DeptCoverage: map[string]int{"Human Resources": 60, "Executive": 20, "Finance": 10}, NoisePct: 1}, + {Name: "Workday - Recruiting", DeptCoverage: map[string]int{"Human Resources": 70}, NoisePct: 1}, + {Name: "Workday - Learning", DeptCoverage: map[string]int{"Human Resources": 55}, NoisePct: 1}, + + // Other HR tools (no children) {Name: "Greenhouse", DeptCoverage: map[string]int{"Human Resources": 93}, NoisePct: 2}, {Name: "BambooHR", DeptCoverage: map[string]int{"Human Resources": 88}, NoisePct: 1}, {Name: "Culture Amp", DeptCoverage: map[string]int{"Human Resources": 73}, NoisePct: 1}, - - // Product tools - {Name: "Amplitude", DeptCoverage: map[string]int{"Product": 89}, NoisePct: 2}, - {Name: "FullStory", DeptCoverage: map[string]int{"Product": 78}, NoisePct: 1}, - {Name: "Notion", DeptCoverage: map[string]int{"Product": 87, "Engineering": 72}, NoisePct: 4}, + {Name: "Lattice", DeptCoverage: map[string]int{"Human Resources": 70}, NoisePct: 1}, + + // ================================================================== + // ServiceNow (parent + children) + // ================================================================== + {Name: "ServiceNow", DeptCoverage: map[string]int{"Workplace & IT": 92, "Engineering": 15}, NoisePct: 2}, + {Name: "ServiceNow - Admin", DeptCoverage: map[string]int{"Workplace & IT": 40}}, + {Name: "ServiceNow - Incident Management", DeptCoverage: map[string]int{"Workplace & IT": 75, "Engineering": 30}, NoisePct: 1}, + {Name: "ServiceNow - Change Management", DeptCoverage: map[string]int{"Workplace & IT": 65, "Engineering": 20}, NoisePct: 1}, + {Name: "ServiceNow - CMDB", DeptCoverage: map[string]int{"Workplace & IT": 55, "Engineering": 15}, NoisePct: 1}, + {Name: "ServiceNow - Asset Management", DeptCoverage: map[string]int{"Workplace & IT": 50, "Finance": 15}, NoisePct: 1}, + + // Other Workplace & IT tools (no children) + {Name: "Jamf", DeptCoverage: map[string]int{"Workplace & IT": 85}, NoisePct: 1}, + {Name: "Duo Security", DeptCoverage: map[string]int{"Workplace & IT": 90, "Engineering": 50}, NoisePct: 2}, + + // ================================================================== + // DocuSign (parent + children) + // ================================================================== + {Name: "DocuSign", DeptCoverage: map[string]int{"Legal": 95, "Sales": 25, "Finance": 30, "Human Resources": 20}, NoisePct: 3}, + {Name: "DocuSign - Admin", DeptCoverage: map[string]int{"Legal": 20, "Workplace & IT": 15}}, + {Name: "DocuSign - Template Management", DeptCoverage: map[string]int{"Legal": 60, "Sales": 15, "Human Resources": 10}, NoisePct: 1}, + {Name: "DocuSign - PowerForms", DeptCoverage: map[string]int{"Legal": 40, "Human Resources": 15}, NoisePct: 1}, + + // Other legal tools + {Name: "Ironclad", DeptCoverage: map[string]int{"Legal": 88}, NoisePct: 1}, + + // ================================================================== + // Management tools (Manager+) + // ================================================================== + {Name: "15Five", DeptCoverage: allDepts(95), MinLevel: "Manager"}, + {Name: "Workday Manager Portal", DeptCoverage: allDepts(93), MinLevel: "Manager"}, + {Name: "Lever Hiring Manager", DeptCoverage: map[string]int{ + "Engineering": 80, "Sales": 70, "Marketing": 65, "Finance": 60, + "Human Resources": 95, "Product": 75, "Customer Support": 65, + "Workplace & IT": 60, "Legal": 55, "Executive": 90, + }, MinLevel: "Manager"}, + + // ================================================================== + // Leadership tools (Director+) + // ================================================================== + {Name: "Adaptive Insights", DeptCoverage: allDepts(90), MinLevel: "Director"}, + {Name: "Headcount Planning", DeptCoverage: allDepts(88), MinLevel: "Director"}, + + // ================================================================== + // Executive tools (VP+) + // ================================================================== + {Name: "Board Report Portal", DeptCoverage: allDepts(100), MinLevel: "VP"}, + {Name: "Strategic Planning Suite", DeptCoverage: allDepts(100), MinLevel: "VP"}, + {Name: "Executive Dashboard", DeptCoverage: map[string]int{"Executive": 100}, MinLevel: "C-Suite"}, + + // ================================================================== + // Regional compliance tools + // ================================================================== + {Name: "GDPR Compliance Portal", DeptCoverage: map[string]int{ + "Legal": 95, "Engineering": 40, "Human Resources": 50, "Workplace & IT": 60, + }, RegionOnly: "EMEA", NoisePct: 5}, + {Name: "SOC2 Dashboard", DeptCoverage: map[string]int{ + "Legal": 90, "Engineering": 35, "Workplace & IT": 55, + }, RegionOnly: "Americas", NoisePct: 3}, + {Name: "APAC CRM Module", DeptCoverage: map[string]int{ + "Sales": 90, "Customer Support": 85, + }, RegionOnly: "APAC", NoisePct: 2}, } -// Realistic first names for user generation. +// --- Multinational names --- + var firstNames = []string{ "James", "Mary", "Robert", "Patricia", "John", "Jennifer", "Michael", "Linda", "David", "Elizabeth", "William", "Barbara", "Richard", "Susan", "Joseph", "Jessica", "Thomas", "Sarah", "Christopher", "Karen", "Charles", "Lisa", "Daniel", "Nancy", - "Matthew", "Betty", "Anthony", "Dorothy", "Mark", "Sandra", "Donald", "Ashley", - "Steven", "Kimberly", "Paul", "Emily", "Andrew", "Donna", "Joshua", "Michelle", - "Kenneth", "Carol", "Kevin", "Amanda", "Brian", "Melissa", "George", "Deborah", - "Timothy", "Stephanie", "Ronald", "Rebecca", "Jason", "Sharon", "Edward", "Laura", - "Jeffrey", "Cynthia", "Ryan", "Kathleen", "Jacob", "Amy", "Gary", "Angela", - "Nicholas", "Shirley", "Eric", "Brenda", "Jonathan", "Emma", "Stephen", "Anna", - "Larry", "Pamela", "Justin", "Nicole", "Scott", "Samantha", "Brandon", "Katherine", - "Benjamin", "Christine", "Samuel", "Helen", "Raymond", "Debra", "Gregory", "Rachel", - "Frank", "Carolyn", "Alexander", "Janet", "Patrick", "Catherine", "Jack", "Maria", -} - -// Realistic last names for user generation. + "Matthew", "Ashley", "Anthony", "Emily", "Mark", "Donna", "Steven", "Kimberly", + "Carlos", "Maria", "Luis", "Isabella", "Diego", "Sofia", "Miguel", "Valentina", + "Alejandro", "Camila", "Fernando", "Lucia", "Wei", "Yuki", "Hiroshi", "Mei", + "Jun", "Sakura", "Kenji", "Ling", "Takeshi", "Hana", "Min", "Soo", + "Raj", "Priya", "Arun", "Deepa", "Vikram", "Anita", "Sanjay", "Kavitha", + "Arjun", "Neha", "Ravi", "Sunita", "Erik", "Astrid", "Pierre", "Amelie", + "Hans", "Ingrid", "Marco", "Giulia", "Stefan", "Katarina", "Liam", "Saoirse", + "Kwame", "Amara", "Olumide", "Fatima", "Tendai", "Zainab", "Omar", "Layla", + "Hassan", "Noor", "Ali", "Yasmin", "Yusuf", "Aisha", "Pavel", "Elena", +} + var lastNames = []string{ - "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", - "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", - "Thomas", "Taylor", "Moore", "Jackson", "Martin", "Lee", "Perez", "Thompson", - "White", "Harris", "Sanchez", "Clark", "Ramirez", "Lewis", "Robinson", "Walker", - "Young", "Allen", "King", "Wright", "Scott", "Torres", "Nguyen", "Hill", - "Flores", "Green", "Adams", "Nelson", "Baker", "Hall", "Rivera", "Campbell", - "Mitchell", "Carter", "Roberts", "Gomez", "Phillips", "Evans", "Turner", "Diaz", - "Parker", "Cruz", "Edwards", "Collins", "Reyes", "Stewart", "Morris", "Morales", - "Murphy", "Cook", "Rogers", "Gutierrez", "Ortiz", "Morgan", "Cooper", "Peterson", - "Bailey", "Reed", "Kelly", "Howard", "Ramos", "Kim", "Cox", "Ward", - "Richardson", "Watson", "Brooks", "Chavez", "Wood", "James", "Bennett", "Gray", - "Mendoza", "Ruiz", "Hughes", "Price", "Alvarez", "Castillo", "Sanders", "Patel", -} - -// getDepartment returns the department and job title for a given user index. -func getDepartment(userIdx, totalUsers int) (string, string) { - // Map user index to a percentage bucket (0-99) - pct := (userIdx * 100) / totalUsers - for _, dept := range departments { - if pct < dept.Weight { - titleIdx := userIdx % len(dept.JobTitles) - return dept.Name, dept.JobTitles[titleIdx] - } - } - // Fallback to last department - last := departments[len(departments)-1] - return last.Name, last.JobTitles[userIdx%len(last.JobTitles)] -} - -// isManagerTitle returns true if the job title is a management role. -func isManagerTitle(title string) bool { - switch title { - case "Engineering Manager", "Sales Manager", "Marketing Manager", - "HR Manager", "Controller", "Senior Product Manager": - return true + "Smith", "Johnson", "Williams", "Brown", "Jones", "Davis", "Miller", "Wilson", + "Taylor", "Anderson", "Thomas", "Moore", "Jackson", "Martin", "Thompson", "White", + "Harris", "Clark", "Lewis", "Robinson", "Walker", "Young", "Allen", "King", + "Garcia", "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Sanchez", + "Ramirez", "Torres", "Flores", "Rivera", "Cruz", "Reyes", "Morales", "Gutierrez", + "Chen", "Wang", "Li", "Zhang", "Liu", "Patel", "Sharma", "Kumar", + "Singh", "Kim", "Park", "Nguyen", "Tanaka", "Yamamoto", "Sato", + "Mueller", "Schmidt", "Johansson", "Eriksson", "Dubois", "Laurent", "Rossi", + "Bianchi", "O'Brien", "O'Connor", "Kowalski", "Okafor", "Mensah", "Diallo", + "Ibrahim", "Al-Rashid", "Nasser", "Abboud", "Petrov", "Volkov", "Santos", + "Ferreira", "Nakamura", "Takahashi", "Andersen", "Larsen", "Virtanen", "Makinen", + "Osei", "Adeyemi", "Kone", "Ben-David", "Cohen", "Novak", "Horvat", +} + +// --- Edge case detection --- + +// ~2.5% of IC/Senior IC/Contractor are disabled former employees with stale group access. +func isDisabledAccount(userIdx, totalUsers int, level string) bool { + if totalUsers < 50 || userIdx < numCxO { + return false } - return false + if level != "IC" && level != "Senior IC" && level != "Contractor" { + return false + } + return userIdx%41 == 40 } -// getManagerEmail returns the email of the first manager in the same department. -// For managers themselves, returns the email of the next manager (or empty if they're the only one). -// totalUsers must match the value used during generation. -func getManagerEmail(userIdx, totalUsers int) string { - dept, title := getDepartment(userIdx, totalUsers) - isManager := isManagerTitle(title) +// ~1.5% have no manager (orphaned in the hierarchy). +func isOrphaned(userIdx, totalUsers int) bool { + if totalUsers < 50 { + return false + } + return userIdx > 30 && userIdx%47 == 46 +} - // Scan all users to find the first manager in the same department. - for i := 0; i < totalUsers; i++ { - if i == userIdx { - continue +// A few ICs/Senior ICs have access far beyond their level. +func isOverPrivileged(userIdx, totalUsers int, level string) bool { + if totalUsers < 50 { + return false + } + if level != "IC" && level != "Senior IC" { + return false + } + return userIdx%53 == 52 +} + +// Some Product/CS employees transferred from another department and retain old access. +func isTransferred(userIdx, totalUsers int, dept string) bool { + if totalUsers < 50 { + return false + } + if dept != "Product" && dept != "Customer Support" { + return false + } + return userIdx%43 == 42 +} + +func getTransferredFromDept(userIdx int, dept string) string { + if dept == "Product" { + return "Engineering" + } + return "Sales" +} + +// A few Senior ICs act as interim managers and have manager-level tool access. +func isActingManager(userIdx, totalUsers int, level string) bool { + if totalUsers < 50 { + return false + } + if level != "Senior IC" { + return false + } + return userIdx%59 == 58 +} + +// --- Org chart construction --- + +func buildOrgChart(totalUsers int) []orgPosition { + positions := make([]orgPosition, totalUsers) + + effectiveCxO := numCxO + if totalUsers < numCxO { + effectiveCxO = totalUsers + } + effectiveSpecial := numSpecialAccounts + if totalUsers <= numCxO+numSpecialAccounts { + effectiveSpecial = 0 + } + + // C-Suite (always Americas HQ) + for i := 0; i < effectiveCxO; i++ { + positions[i] = orgPosition{ + Title: cSuitePositions[i].Title, + Department: "Executive", + Level: "C-Suite", + Region: "Americas", + EmploymentType: "Full-time", + Enabled: true, + ManagerIdx: -1, } - d, t := getDepartment(i, totalUsers) - if d != dept { - continue + if i > 0 { + positions[i].ManagerIdx = 0 + } + } + if totalUsers <= effectiveCxO { + return positions + } + + // Regular employees + regularStart := effectiveCxO + regularEnd := totalUsers - effectiveSpecial + regularCount := regularEnd - regularStart + if regularCount <= 0 { + return positions + } + + for i := regularStart; i < regularEnd; i++ { + relIdx := i - regularStart + pct := (relIdx * 100) / regularCount + + var dept *departmentDef + for j := range departments { + if pct < departments[j].Weight { + dept = &departments[j] + break + } + } + if dept == nil { + dept = &departments[len(departments)-1] + } + + deptStart, deptEnd := getDeptBounds(regularStart, regularCount, dept.Name) + deptSize := deptEnd - deptStart + if deptSize <= 0 { + deptSize = 1 } - if !isManagerTitle(t) { + posInDept := i - deptStart + + levelPct := (posInDept * 100) / deptSize + level := "IC" + for _, l := range levelDistribution { + if levelPct < l.Weight { + level = l.Name + break + } + } + + titles := dept.Titles[level] + title := level + if len(titles) > 0 { + title = titles[posInDept%len(titles)] + } + + empType := "Full-time" + if level == "Contractor" { + empType = "Contractor" + } + + enabled := !isDisabledAccount(i, totalUsers, level) + + positions[i] = orgPosition{ + Title: title, + Department: dept.Name, + Level: level, + Region: getRegionForUser(i), + EmploymentType: empType, + Enabled: enabled, + ManagerIdx: -1, + } + } + + // Special accounts + specialStart := regularEnd + for j := 0; j < numServiceAccounts && specialStart+j < totalUsers; j++ { + sa := serviceAccountDefs[j] + positions[specialStart+j] = orgPosition{ + Title: sa.Name, + Department: sa.Dept, + Level: "IC", + Region: "Americas", + EmploymentType: "Service Account", + Enabled: true, + ManagerIdx: -1, + } + } + for j := 0; j < numSharedAccounts && specialStart+numServiceAccounts+j < totalUsers; j++ { + sa := sharedAccountDefs[j] + positions[specialStart+numServiceAccounts+j] = orgPosition{ + Title: sa.Name, + Department: sa.Dept, + Level: "IC", + Region: "Americas", + EmploymentType: "Shared Account", + Enabled: true, + ManagerIdx: -1, + } + } + + // Manager chain (pass 2) + for i := regularStart; i < regularEnd; i++ { + if isOrphaned(i, totalUsers) { continue } - // Non-managers report to the first manager found. - // Managers report to a different manager (not themselves). - if !isManager || i != userIdx { - first, last := getUserName(i) - return fmt.Sprintf("%s.%s.%d@example.com", first, last, i) + positions[i].ManagerIdx = findManager(positions, i, regularStart, regularEnd) + } + + return positions +} + +func getDeptBounds(regularStart, regularCount int, deptName string) (int, int) { + prevWeight := 0 + for _, d := range departments { + start := regularStart + (prevWeight * regularCount / 100) + end := regularStart + (d.Weight * regularCount / 100) + if d.Name == deptName { + return start, end + } + prevWeight = d.Weight + } + return regularStart, regularStart +} + +func getRegionForUser(userIdx int) string { + h := uint32(userIdx) * 2654435761 + h ^= h >> 16 + pct := int(h % 100) + for _, r := range regionDistribution { + if pct < r.Weight { + return r.Name + } + } + return "Americas" +} + +func findManager(positions []orgPosition, userIdx, regularStart, regularEnd int) int { + pos := positions[userIdx] + targetLevel, ok := parentLevel[pos.Level] + if !ok { + return -1 + } + + dept := pos.Department + + for targetLevel != "" { + if targetLevel == "C-Suite" { + for _, d := range departments { + if d.Name == dept { + return d.CxoIdx + } + } + return 0 + } + + var candidates []int + for j := regularStart; j < regularEnd; j++ { + if j == userIdx { + continue + } + if positions[j].Department == dept && positions[j].Level == targetLevel && positions[j].Enabled { + candidates = append(candidates, j) + } + } + if len(candidates) > 0 { + return candidates[userIdx%len(candidates)] + } + + next, ok := parentLevel[targetLevel] + if !ok { + break + } + targetLevel = next + } + + for _, d := range departments { + if d.Name == dept { + return d.CxoIdx } } - return "" + return 0 } -// getUserName returns a realistic first/last name for a user index. +// --- Data generation helpers --- + func getUserName(userIdx int) (string, string) { first := firstNames[userIdx%len(firstNames)] - // Offset last name index to avoid repetitive "James Smith, Mary Johnson" patterns last := lastNames[(userIdx*7+userIdx/len(firstNames))%len(lastNames)] return first, last } -// shouldAssign is a deterministic hash-like function that decides group membership. -// Returns true if the user at userIdx should be a member of the group at groupIdx -// given the coverage percentage. +// shouldAssign is a deterministic hash-like function for group membership decisions. func shouldAssign(userIdx, groupIdx, coveragePct int) bool { if coveragePct >= 100 { return true @@ -241,32 +775,40 @@ func shouldAssign(userIdx, groupIdx, coveragePct int) bool { if coveragePct <= 0 { return false } - // Knuth multiplicative hash for good distribution h := uint32(userIdx)*2654435761 + uint32(groupIdx)*2246822519 - // Mix bits for better uniformity h ^= h >> 16 h *= 0x45d9f3b h ^= h >> 16 return int(h%100) < coveragePct } +func userId(i int) string { + return fmt.Sprintf("user-%07d", i) +} + +func groupId(i int) string { + return fmt.Sprintf("group-%07d", i) +} + +// --- Generator --- + type generator struct { config *config.Demo + positions []orgPosition currentUser int currentPassword int currentGroup int currentRole int currentScopedRole int currentProject int - groupsDone bool // true after all app groups + Everyone group are emitted -} - -func userId(i int) string { - return fmt.Sprintf("user-%07d", i) + groupsDone bool } -func groupId(i int) string { - return fmt.Sprintf("group-%07d", i) +func (g *generator) ensurePositions() { + if g.positions != nil { + return + } + g.positions = buildOrgChart(g.config.Users) } func (g *generator) totalAppGroups() int { @@ -274,29 +816,82 @@ func (g *generator) totalAppGroups() int { } func (g *generator) Next() (*dbResource, bool) { - // Phase 1: Generate app-based groups + g.ensurePositions() + + // Phase 1: App-based groups if !g.groupsDone { if g.currentGroup < g.totalAppGroups() { app := appGroups[g.currentGroup] - members := []string{} - admins := []string{} + var members []string + var admins []string for i := 0; i < g.config.Users; i++ { - dept, _ := getDepartment(i, g.config.Users) + pos := g.positions[i] + + if pos.EmploymentType == "Service Account" || pos.EmploymentType == "Shared Account" { + if !g.isUniversalApp(g.currentGroup) { + coverage, inDept := app.DeptCoverage[pos.Department] + if !inDept || !shouldAssign(i, g.currentGroup, coverage) { + continue + } + } + } + + dept := pos.Department coverage, inTargetDept := app.DeptCoverage[dept] assign := false if inTargetDept { assign = shouldAssign(i, g.currentGroup, coverage) } else if app.NoisePct > 0 { - // Cross-department noise access assign = shouldAssign(i, g.currentGroup+1000, app.NoisePct) } + // Level gate + if app.MinLevel != "" && assign { + userLvl := levelOrder[pos.Level] + minLvl := levelOrder[app.MinLevel] + if userLvl < minLvl { + // Acting managers bypass the manager-level gate + if app.MinLevel == "Manager" && isActingManager(i, g.config.Users, pos.Level) { + // allowed + } else { + assign = false + } + } + } + + // Region gate + if app.RegionOnly != "" && assign { + if pos.Region != app.RegionOnly { + assign = false + } + } + + // Edge: transferred users retain old department access + if !assign && isTransferred(i, g.config.Users, pos.Department) { + oldDept := getTransferredFromDept(i, pos.Department) + if oldCov, ok := app.DeptCoverage[oldDept]; ok { + assign = shouldAssign(i, g.currentGroup, oldCov) + } + } + + // Edge: over-privileged ICs gain random extra access + if !assign && isOverPrivileged(i, g.config.Users, pos.Level) { + assign = shouldAssign(i, g.currentGroup+3000, 30) + } + if assign { members = append(members, userId(i)) - // Make ~2% of members admins - if shouldAssign(i, g.currentGroup, 2) { + + isAdmin := shouldAssign(i, g.currentGroup, 2) + if isOverPrivileged(i, g.config.Users, pos.Level) { + isAdmin = shouldAssign(i, g.currentGroup, 25) + } + if inTargetDept && levelOrder[pos.Level] >= levelOrder["VP"] { + isAdmin = true + } + if isAdmin { admins = append(admins, userId(i)) } } @@ -335,7 +930,7 @@ func (g *generator) Next() (*dbResource, bool) { return db, true } - // Phase 2: Projects (use configured count) + // Phase 2: Projects if g.currentProject < g.config.Projects { totalGroups := g.totalAppGroups() db := &dbResource{ @@ -355,15 +950,15 @@ func (g *generator) Next() (*dbResource, bool) { return db, true } - // Phase 3: Roles (use configured count) + // Phase 3: Roles if g.currentRole < g.config.Roles { totalGroups := g.totalAppGroups() - directAssignments := []string{} + var directAssignments []string if g.config.Users > 0 { directAssignments = append(directAssignments, userId(g.currentRole%g.config.Users)) directAssignments = append(directAssignments, userId((g.currentRole*10)%g.config.Users)) } - groupAssignments := []string{} + var groupAssignments []string if totalGroups > 5 { groupAssignments = append(groupAssignments, groupId(g.currentRole%totalGroups)) groupAssignments = append(groupAssignments, groupId((g.currentRole*10)%totalGroups)) @@ -382,9 +977,9 @@ func (g *generator) Next() (*dbResource, bool) { return db, true } - // Phase 4: Scoped roles (use configured count) + // Phase 4: Scoped roles if g.currentScopedRole < g.config.ScopedRoles { - userAssignments := []string{} + var userAssignments []string if g.config.Users > 0 { userAssignments = append(userAssignments, userId(g.currentScopedRole%g.config.Users)) userAssignments = append(userAssignments, userId((g.currentScopedRole*5)%g.config.Users)) @@ -406,22 +1001,49 @@ func (g *generator) Next() (*dbResource, bool) { return db, true } - // Phase 5: Users with department, job title, and manager + // Phase 5: Users if g.currentUser < g.config.Users { + pos := g.positions[g.currentUser] first, last := getUserName(g.currentUser) - fullName := fmt.Sprintf("%s %s", first, last) - email := fmt.Sprintf("%s.%s.%d@example.com", first, last, g.currentUser) - dept, jobTitle := getDepartment(g.currentUser, g.config.Users) + + var fullName, email string + switch pos.EmploymentType { + case "Service Account": + sa := serviceAccountDefs[g.currentUser-(g.config.Users-numSpecialAccounts)] + fullName = sa.Name + email = sa.Email + case "Shared Account": + sa := sharedAccountDefs[g.currentUser-(g.config.Users-numSharedAccounts)] + fullName = sa.Name + email = sa.Email + default: + fullName = fmt.Sprintf("%s %s", first, last) + email = fmt.Sprintf("%s.%s.%d@example.com", first, last, g.currentUser) + } attrs := map[string]string{ - "full_name": fullName, - "email": email, - "department": dept, - "job_title": jobTitle, + "full_name": fullName, + "email": email, + "department": pos.Department, + "job_title": pos.Title, + "level": pos.Level, + "region": pos.Region, + "employment_type": pos.EmploymentType, } - if managerEmail := getManagerEmail(g.currentUser, g.config.Users); managerEmail != "" { - attrs["manager_email"] = managerEmail + if pos.ManagerIdx >= 0 { + mgrPos := g.positions[pos.ManagerIdx] + var mgrEmail string + switch mgrPos.EmploymentType { + case "Service Account", "Shared Account": + mgrEmail = "" + default: + mgrFirst, mgrLast := getUserName(pos.ManagerIdx) + mgrEmail = fmt.Sprintf("%s.%s.%d@example.com", mgrFirst, mgrLast, pos.ManagerIdx) + } + if mgrEmail != "" { + attrs["manager_email"] = mgrEmail + } } db := &dbResource{ @@ -429,7 +1051,7 @@ func (g *generator) Next() (*dbResource, bool) { Id: userId(g.currentUser), Name: fullName, Email: email, - Enabled: true, + Enabled: pos.Enabled, Attrs: attrs, CreatedAt: time.Now(), UpdatedAt: time.Now(), @@ -455,6 +1077,19 @@ func (g *generator) Next() (*dbResource, bool) { return nil, false } +// isUniversalApp returns true if every department has >=100% coverage. +func (g *generator) isUniversalApp(groupIdx int) bool { + app := appGroups[groupIdx] + for _, d := range departments { + if app.DeptCoverage[d.Name] < 100 { + return false + } + } + return app.DeptCoverage["Executive"] >= 100 +} + +// --- Table descriptors (unchanged) --- + var allTableDescriptors = []tableDescriptor{ users, groups, From c10a820f772ee94b04a6bbf6edfeb845f8cc4feb Mon Sep 17 00:00:00 2001 From: Justin Gallardo Date: Thu, 5 Mar 2026 15:08:10 -0800 Subject: [PATCH 3/3] Implement app resource management with CRUD operations and event handling - Added App struct to represent application entities. - Implemented methods for listing, retrieving, granting, and revoking access to apps in the client. - Introduced app event feed for tracking changes to app resources. - Enhanced group handling to associate child groups with their parent apps. - Updated resource types to include app resources for better integration with the connector. --- pkg/client/client.go | 247 ++++++++++++++++++++++++ pkg/client/data.go | 324 +++++++++++++++++++++++--------- pkg/connector/apps.go | 193 +++++++++++++++++++ pkg/connector/connector.go | 2 + pkg/connector/events.go | 62 ++++++ pkg/connector/groups.go | 37 +++- pkg/connector/resource_types.go | 7 + 7 files changed, 786 insertions(+), 86 deletions(-) create mode 100644 pkg/connector/apps.go diff --git a/pkg/client/client.go b/pkg/client/client.go index 655d5a86..2cbe0623 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -93,6 +93,15 @@ type Password struct { UserId string } +type App struct { + Id string + Name string + Members []string + ChildGroups []string + CreatedAt time.Time + UpdatedAt time.Time +} + // Client is a simple example client. While this client would normally be responsible for communicating with an upstream. // API, for this demo the client is only working with in-memory data. type Client struct { @@ -360,6 +369,26 @@ func (c *Client) initDB(ctx context.Context) error { if err != nil { return err } + case dbResource.App != nil: + row := goqu.Record{ + "id": dbResource.App.Id, + "name": dbResource.App.Name, + "members": strings.Join(dbResource.App.Members, ","), + "child_groups": strings.Join(dbResource.App.ChildGroups, ","), + "created_at": dbResource.App.CreatedAt, + "updated_at": dbResource.App.UpdatedAt, + } + baseAppQ := c.db.Insert(apps.Name()).Prepared(true) + baseAppQ = baseAppQ.Rows(row) + baseAppQ = baseAppQ.OnConflict(goqu.DoUpdate("id", row)) + query, args, err := baseAppQ.ToSQL() + if err != nil { + return err + } + _, err = c.db.Exec(query, args...) + if err != nil { + return err + } } } @@ -1617,3 +1646,221 @@ func (c *Client) ListScopedRolesByUpdatedAt(ctx context.Context, updatedAt time. return scopedRolesList, nextPageToken, nil } + +func (c *Client) rowToApp(_ context.Context, row scannable) (*App, error) { + app := &App{} + members := "" + childGroups := "" + err := row.Scan(&app.Id, &app.Name, &members, &childGroups, &app.CreatedAt, &app.UpdatedAt) + if err != nil { + return nil, err + } + + if members != "" { + app.Members = strings.Split(members, ",") + } + if childGroups != "" { + app.ChildGroups = strings.Split(childGroups, ",") + } + return app, nil +} + +// ListApps returns all the apps from the database. +func (c *Client) ListApps(ctx context.Context) ([]*App, error) { + err := c.validateDB() + if err != nil { + return nil, err + } + + q := c.db.From(apps.Name()).Prepared(true) + q = q.Select("id", "name", "members", "child_groups", "created_at", "updated_at") + + query, args, err := q.ToSQL() + if err != nil { + return nil, err + } + + rows, err := c.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + appsList := []*App{} + for rows.Next() { + app, err := c.rowToApp(ctx, rows) + if err != nil { + return nil, err + } + appsList = append(appsList, app) + } + + return appsList, nil +} + +// GetApp returns the app requested if it exists, else returns an error. +func (c *Client) GetApp(ctx context.Context, appID string) (*App, error) { + err := c.validateDB() + if err != nil { + return nil, err + } + + q := c.db.From(apps.Name()).Prepared(true) + q = q.Select("id", "name", "members", "child_groups", "created_at", "updated_at") + q = q.Where(goqu.C("id").Eq(appID)) + + query, args, err := q.ToSQL() + if err != nil { + return nil, err + } + + row := c.db.QueryRowContext(ctx, query, args...) + return c.rowToApp(ctx, row) +} + +func (c *Client) ListAppsByUpdatedAt(ctx context.Context, updatedAt time.Time, sToken *pagination.StreamToken) ([]*App, string, error) { + err := c.validateDB() + if err != nil { + return nil, "", err + } + + limit := 50 + offset := 0 + if sToken != nil { + limit = sToken.Size + if sToken.Cursor != "" { + offset, err = strconv.Atoi(sToken.Cursor) + if err != nil { + return nil, "", err + } + } + } + limit = min(max(limit, minEvents), maxEvents) + + if offset < 0 { + return nil, "", status.Errorf(codes.InvalidArgument, "offset cannot be negative") + } + + q := c.db.From(apps.Name()).Prepared(true). + Select("id", "name", "members", "child_groups", "created_at", "updated_at"). + Where(goqu.C("updated_at").Gt(updatedAt)). + Order(goqu.C("updated_at").Desc()). + Limit(uint(limit)). //nolint:gosec // This won't underflow + Offset(uint(offset)) + + query, args, err := q.ToSQL() + if err != nil { + return nil, "", err + } + + rows, err := c.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, "", err + } + defer rows.Close() + + appsList := []*App{} + for rows.Next() { + app, err := c.rowToApp(ctx, rows) + if err != nil { + return nil, "", err + } + appsList = append(appsList, app) + } + + nextPageToken := "" + if len(appsList) == limit { + nextPageToken = strconv.Itoa(offset + limit) + } + + return appsList, nextPageToken, nil +} + +func (c *Client) GrantAppAccess(ctx context.Context, appID, userID string) error { + err := c.validateDB() + if err != nil { + return err + } + + app, err := c.GetApp(ctx, appID) + if err != nil { + return err + } + + _, err = c.GetUser(ctx, userID) + if err != nil { + return err + } + + if slices.Contains(app.Members, userID) { + return nil + } + + app.Members = append(app.Members, userID) + q := c.db.Update(apps.Name()).Prepared(true) + q = q.Set(goqu.Record{ + "members": strings.Join(app.Members, ","), + "updated_at": time.Now(), + }) + q = q.Where(goqu.C("id").Eq(appID)) + + query, args, err := q.ToSQL() + if err != nil { + return err + } + + _, err = c.db.ExecContext(ctx, query, args...) + if err != nil { + return err + } + + return nil +} + +func (c *Client) RevokeAppAccess(ctx context.Context, appID, userID string) error { + err := c.validateDB() + if err != nil { + return err + } + + app, err := c.GetApp(ctx, appID) + if err != nil { + return err + } + + _, err = c.GetUser(ctx, userID) + if err != nil { + return err + } + + found := false + for i, u := range app.Members { + if u == userID { + app.Members = append(app.Members[:i], app.Members[i+1:]...) + found = true + break + } + } + if !found { + return nil + } + + q := c.db.Update(apps.Name()).Prepared(true) + q = q.Set(goqu.Record{ + "members": strings.Join(app.Members, ","), + "updated_at": time.Now(), + }) + q = q.Where(goqu.C("id").Eq(appID)) + + query, args, err := q.ToSQL() + if err != nil { + return err + } + + _, err = c.db.ExecContext(ctx, query, args...) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/client/data.go b/pkg/client/data.go index 0a766ffd..d218597d 100644 --- a/pkg/client/data.go +++ b/pkg/client/data.go @@ -2,6 +2,7 @@ package client import ( "fmt" + "strings" "time" "github.com/conductorone/baton-demo/pkg/config" @@ -14,6 +15,7 @@ type dbResource struct { ScopedRole *ScopedRole Project *Project Password *Password + App *App } func (r *dbResource) String() string { @@ -30,6 +32,8 @@ func (r *dbResource) String() string { return fmt.Sprintf("Project: id %s name '%s' owner %s %d groups", r.Project.Id, r.Project.Name, r.Project.Owner, len(r.Project.GroupAssignments)) case r.Password != nil: return fmt.Sprintf("Password: id %s userid %s", r.Password.Id, r.Password.UserId) + case r.App != nil: + return fmt.Sprintf("App: id %s name '%s' %d members %d child_groups", r.App.Id, r.App.Name, len(r.App.Members), len(r.App.ChildGroups)) } return "Unknown" } @@ -460,6 +464,66 @@ var appGroups = []appDef{ }, RegionOnly: "APAC", NoisePct: 2}, } +// --- App-to-group mapping --- + +type appMapping struct { + ParentIdx int + ChildIndices []int +} + +// buildAppMappings walks appGroups and identifies which entries are top-level +// apps vs child groups. A child group has a name like "ParentApp - ChildName" +// where ParentApp matches a preceding entry that has no " - " in its name. +func buildAppMappings() []appMapping { + // First, collect all top-level (parent) names and their indices + type parentInfo struct { + idx int + name string + } + var parents []parentInfo + isChild := make(map[int]bool) + + for i, ag := range appGroups { + if !strings.Contains(ag.Name, " - ") { + parents = append(parents, parentInfo{idx: i, name: ag.Name}) + } + } + + // For each entry with " - ", check if its prefix matches a known parent + for i, ag := range appGroups { + if idx := strings.Index(ag.Name, " - "); idx > 0 { + prefix := ag.Name[:idx] + for _, p := range parents { + if p.name == prefix { + isChild[i] = true + break + } + } + } + } + + // Build mappings + var mappings []appMapping + for _, p := range parents { + m := appMapping{ParentIdx: p.idx} + for i, ag := range appGroups { + if isChild[i] { + prefix := ag.Name[:strings.Index(ag.Name, " - ")] + if prefix == p.name { + m.ChildIndices = append(m.ChildIndices, i) + } + } + } + mappings = append(mappings, m) + } + + return mappings +} + +func appId(i int) string { + return fmt.Sprintf("app-%07d", i) +} + // --- Multinational names --- var firstNames = []string{ @@ -801,7 +865,10 @@ type generator struct { currentRole int currentScopedRole int currentProject int + currentApp int groupsDone bool + appsDone bool + appMappings []appMapping } func (g *generator) ensurePositions() { @@ -809,93 +876,123 @@ func (g *generator) ensurePositions() { return } g.positions = buildOrgChart(g.config.Users) + g.appMappings = buildAppMappings() } func (g *generator) totalAppGroups() int { return len(appGroups) } -func (g *generator) Next() (*dbResource, bool) { - g.ensurePositions() +// validGroupIndices returns the appGroups indices that are actual groups (not top-level apps). +func (g *generator) validGroupIndices() []int { + var indices []int + for i := range appGroups { + if !g.isTopLevelApp(i) { + indices = append(indices, i) + } + } + return indices +} - // Phase 1: App-based groups - if !g.groupsDone { - if g.currentGroup < g.totalAppGroups() { - app := appGroups[g.currentGroup] - var members []string - var admins []string - - for i := 0; i < g.config.Users; i++ { - pos := g.positions[i] - - if pos.EmploymentType == "Service Account" || pos.EmploymentType == "Shared Account" { - if !g.isUniversalApp(g.currentGroup) { - coverage, inDept := app.DeptCoverage[pos.Department] - if !inDept || !shouldAssign(i, g.currentGroup, coverage) { - continue - } - } - } +// isTopLevelApp returns true if the given appGroups index is a parent app entry. +func (g *generator) isTopLevelApp(groupIdx int) bool { + for _, m := range g.appMappings { + if m.ParentIdx == groupIdx { + return true + } + } + return false +} - dept := pos.Department - coverage, inTargetDept := app.DeptCoverage[dept] +// computeGroupMembers computes the members and admins for a given appGroups index. +func (g *generator) computeGroupMembers(groupIdx int) (members []string, admins []string) { + app := appGroups[groupIdx] - assign := false - if inTargetDept { - assign = shouldAssign(i, g.currentGroup, coverage) - } else if app.NoisePct > 0 { - assign = shouldAssign(i, g.currentGroup+1000, app.NoisePct) - } + for i := 0; i < g.config.Users; i++ { + pos := g.positions[i] - // Level gate - if app.MinLevel != "" && assign { - userLvl := levelOrder[pos.Level] - minLvl := levelOrder[app.MinLevel] - if userLvl < minLvl { - // Acting managers bypass the manager-level gate - if app.MinLevel == "Manager" && isActingManager(i, g.config.Users, pos.Level) { - // allowed - } else { - assign = false - } - } + if pos.EmploymentType == "Service Account" || pos.EmploymentType == "Shared Account" { + if !g.isUniversalApp(groupIdx) { + coverage, inDept := app.DeptCoverage[pos.Department] + if !inDept || !shouldAssign(i, groupIdx, coverage) { + continue } + } + } - // Region gate - if app.RegionOnly != "" && assign { - if pos.Region != app.RegionOnly { - assign = false - } - } + dept := pos.Department + coverage, inTargetDept := app.DeptCoverage[dept] - // Edge: transferred users retain old department access - if !assign && isTransferred(i, g.config.Users, pos.Department) { - oldDept := getTransferredFromDept(i, pos.Department) - if oldCov, ok := app.DeptCoverage[oldDept]; ok { - assign = shouldAssign(i, g.currentGroup, oldCov) - } - } + assign := false + if inTargetDept { + assign = shouldAssign(i, groupIdx, coverage) + } else if app.NoisePct > 0 { + assign = shouldAssign(i, groupIdx+1000, app.NoisePct) + } - // Edge: over-privileged ICs gain random extra access - if !assign && isOverPrivileged(i, g.config.Users, pos.Level) { - assign = shouldAssign(i, g.currentGroup+3000, 30) + // Level gate + if app.MinLevel != "" && assign { + userLvl := levelOrder[pos.Level] + minLvl := levelOrder[app.MinLevel] + if userLvl < minLvl { + if app.MinLevel == "Manager" && isActingManager(i, g.config.Users, pos.Level) { + // allowed + } else { + assign = false } + } + } - if assign { - members = append(members, userId(i)) - - isAdmin := shouldAssign(i, g.currentGroup, 2) - if isOverPrivileged(i, g.config.Users, pos.Level) { - isAdmin = shouldAssign(i, g.currentGroup, 25) - } - if inTargetDept && levelOrder[pos.Level] >= levelOrder["VP"] { - isAdmin = true - } - if isAdmin { - admins = append(admins, userId(i)) - } - } + // Region gate + if app.RegionOnly != "" && assign { + if pos.Region != app.RegionOnly { + assign = false } + } + + // Edge: transferred users retain old department access + if !assign && isTransferred(i, g.config.Users, pos.Department) { + oldDept := getTransferredFromDept(i, pos.Department) + if oldCov, ok := app.DeptCoverage[oldDept]; ok { + assign = shouldAssign(i, groupIdx, oldCov) + } + } + + // Edge: over-privileged ICs gain random extra access + if !assign && isOverPrivileged(i, g.config.Users, pos.Level) { + assign = shouldAssign(i, groupIdx+3000, 30) + } + + if assign { + members = append(members, userId(i)) + + isAdmin := shouldAssign(i, groupIdx, 2) + if isOverPrivileged(i, g.config.Users, pos.Level) { + isAdmin = shouldAssign(i, groupIdx, 25) + } + if inTargetDept && levelOrder[pos.Level] >= levelOrder["VP"] { + isAdmin = true + } + if isAdmin { + admins = append(admins, userId(i)) + } + } + } + + return members, admins +} + +func (g *generator) Next() (*dbResource, bool) { + g.ensurePositions() + + // Phase 1: App-based groups (skip top-level app entries; they become app resources) + if !g.groupsDone { + for g.currentGroup < g.totalAppGroups() && g.isTopLevelApp(g.currentGroup) { + g.currentGroup++ + } + if g.currentGroup < g.totalAppGroups() { + app := appGroups[g.currentGroup] + members, admins := g.computeGroupMembers(g.currentGroup) db := &dbResource{ Group: &Group{ @@ -930,20 +1027,56 @@ func (g *generator) Next() (*dbResource, bool) { return db, true } + // Phase 1.5: App resources + if !g.appsDone { + if g.currentApp < len(g.appMappings) { + m := g.appMappings[g.currentApp] + app := appGroups[m.ParentIdx] + + // App members = what would have been the top-level group's members + members, _ := g.computeGroupMembers(m.ParentIdx) + + // child_groups = group IDs of the child entries + var childGroups []string + for _, childIdx := range m.ChildIndices { + childGroups = append(childGroups, groupId(childIdx)) + } + + db := &dbResource{ + App: &App{ + Id: appId(g.currentApp), + Name: app.Name, + Members: members, + ChildGroups: childGroups, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } + g.currentApp++ + return db, true + } + g.appsDone = true + } + // Phase 2: Projects if g.currentProject < g.config.Projects { - totalGroups := g.totalAppGroups() + validGroups := g.validGroupIndices() + totalValid := len(validGroups) + var groupAssignments []string + if totalValid > 0 { + groupAssignments = []string{ + groupId(validGroups[g.currentProject%totalValid]), + groupId(validGroups[(g.currentProject*10)%totalValid]), + } + } db := &dbResource{ Project: &Project{ - Id: fmt.Sprintf("project-%07d", g.currentProject), - Name: fmt.Sprintf("Project %07d", g.currentProject), - Owner: userId(g.currentProject % g.config.Users), - GroupAssignments: []string{ - groupId(g.currentProject % totalGroups), - groupId((g.currentProject * 10) % totalGroups), - }, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + Id: fmt.Sprintf("project-%07d", g.currentProject), + Name: fmt.Sprintf("Project %07d", g.currentProject), + Owner: userId(g.currentProject % g.config.Users), + GroupAssignments: groupAssignments, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), }, } g.currentProject++ @@ -952,16 +1085,17 @@ func (g *generator) Next() (*dbResource, bool) { // Phase 3: Roles if g.currentRole < g.config.Roles { - totalGroups := g.totalAppGroups() + validGroups := g.validGroupIndices() + totalValid := len(validGroups) var directAssignments []string if g.config.Users > 0 { directAssignments = append(directAssignments, userId(g.currentRole%g.config.Users)) directAssignments = append(directAssignments, userId((g.currentRole*10)%g.config.Users)) } var groupAssignments []string - if totalGroups > 5 { - groupAssignments = append(groupAssignments, groupId(g.currentRole%totalGroups)) - groupAssignments = append(groupAssignments, groupId((g.currentRole*10)%totalGroups)) + if totalValid > 5 { + groupAssignments = append(groupAssignments, groupId(validGroups[g.currentRole%totalValid])) + groupAssignments = append(groupAssignments, groupId(validGroups[(g.currentRole*10)%totalValid])) } db := &dbResource{ Role: &Role{ @@ -1097,6 +1231,7 @@ var allTableDescriptors = []tableDescriptor{ scopedRoles, projects, passwords, + apps, } type tableDescriptor interface { @@ -1227,3 +1362,24 @@ func (t *passwordsTable) Schema() ([]string, []any) { "CREATE TABLE IF NOT EXISTS passwords (id TEXT PRIMARY KEY, password TEXT NOT NULL, user_id TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id))", }, []any{} } + +var apps = (*appsTable)(nil) + +type appsTable struct{} + +func (t *appsTable) Name() string { + return "apps" +} + +func (t *appsTable) Schema() ([]string, []any) { + return []string{ + `CREATE TABLE IF NOT EXISTS apps ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + members TEXT NOT NULL, + child_groups TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + }, []any{} +} diff --git a/pkg/connector/apps.go b/pkg/connector/apps.go new file mode 100644 index 00000000..0d36a26a --- /dev/null +++ b/pkg/connector/apps.go @@ -0,0 +1,193 @@ +package connector + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/conductorone/baton-demo/pkg/client" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/pagination" + sdkEntitlement "github.com/conductorone/baton-sdk/pkg/types/entitlement" + sdkGrant "github.com/conductorone/baton-sdk/pkg/types/grant" + "github.com/conductorone/baton-sdk/pkg/types/resource" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var appAccessEntitlement = "access" + +type appBuilder struct { + client *client.Client +} + +func (o *appBuilder) ResourceType(ctx context.Context) *v2.ResourceType { + return appResourceType +} + +func appResource(a *client.App, parentResourceID *v2.ResourceId) (*v2.Resource, error) { + profile := make(map[string]any) + profile["app_name"] = a.Name + profile["member_count"] = len(a.Members) + profile["child_group_count"] = len(a.ChildGroups) + profile["created_at"] = a.CreatedAt.Format(time.RFC3339) + profile["updated_at"] = a.UpdatedAt.Format(time.RFC3339) + + return resource.NewAppResource( + a.Name, + appResourceType, + a.Id, + []resource.AppTraitOption{resource.WithAppProfile(profile)}, + resource.WithParentResourceID(parentResourceID), + ) +} + +func (o *appBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, ops resource.SyncOpAttrs) ([]*v2.Resource, *resource.SyncOpResults, error) { + apps, err := o.client.ListApps(ctx) + if err != nil { + return nil, nil, err + } + + var ret []*v2.Resource + for _, a := range apps { + app, err := appResource(a, parentResourceID) + if err != nil { + return nil, nil, err + } + ret = append(ret, app) + } + + return ret, nil, nil +} + +func (o *appBuilder) Get(ctx context.Context, resourceId *v2.ResourceId, parentResourceId *v2.ResourceId) (*v2.Resource, annotations.Annotations, error) { + app, err := o.client.GetApp(ctx, resourceId.Resource) + if err != nil { + return nil, nil, err + } + r, err := appResource(app, parentResourceId) + if err != nil { + return nil, nil, err + } + return r, nil, nil +} + +func (o *appBuilder) Entitlements(ctx context.Context, r *v2.Resource, ops resource.SyncOpAttrs) ([]*v2.Entitlement, *resource.SyncOpResults, error) { + access := sdkEntitlement.NewAssignmentEntitlement(r, appAccessEntitlement, sdkEntitlement.WithGrantableTo(userResourceType)) + access.Description = fmt.Sprintf("Has access to the %s app", r.DisplayName) + + return []*v2.Entitlement{access}, nil, nil +} + +func (o *appBuilder) Grants(ctx context.Context, r *v2.Resource, ops resource.SyncOpAttrs) ([]*v2.Grant, *resource.SyncOpResults, error) { + b := &pagination.Bag{} + err := b.Unmarshal(ops.PageToken.Token) + if err != nil { + return nil, nil, err + } + + if b.Current() == nil { + b.Push(pagination.PageState{ + ResourceTypeID: "members", + Token: "0", + }) + } + + app, err := o.client.GetApp(ctx, r.Id.Resource) + if err != nil { + return nil, nil, err + } + + limit := ops.PageToken.Size + if limit == 0 { + limit = 1000 + } + + ps := b.Current() + + offset, err := strconv.Atoi(ps.Token) + if err != nil { + return nil, nil, err + } + + var ret []*v2.Grant + + switch ps.ResourceTypeID { + case "members": + end := min(offset+limit, len(app.Members)) + for _, memberID := range app.Members[offset:end] { + pID, err := resource.NewResourceID(userResourceType, memberID) + if err != nil { + return nil, nil, err + } + ret = append(ret, sdkGrant.NewGrant(r, appAccessEntitlement, pID)) + } + nextPage := "" + if end < len(app.Members) { + nextPage = strconv.Itoa(end) + } + nextPageToken, err := b.NextToken(nextPage) + if err != nil { + return nil, nil, err + } + return ret, &resource.SyncOpResults{NextPageToken: nextPageToken}, nil + default: + return nil, nil, fmt.Errorf("unknown resource type") + } +} + +func parseAppID(entitlementID string) (string, string, error) { + parts := strings.Split(entitlementID, ":") + if len(parts) != 3 { + return "", "", fmt.Errorf("invalid app entitlement ID %s", entitlementID) + } + return parts[1], parts[2], nil +} + +func (o *appBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) { + if principal.Id.ResourceType != userResourceType.Id { + return nil, nil, fmt.Errorf("baton-demo: only users can have app access granted") + } + + if entitlement.Resource.Id.ResourceType != appResourceType.Id { + return nil, nil, fmt.Errorf("baton-demo: only apps can have access granted") + } + + appID, _, err := parseAppID(entitlement.Id) + if err != nil { + return nil, nil, err + } + userID := principal.Id.Resource + + err = o.client.GrantAppAccess(ctx, appID, userID) + if err != nil { + return nil, nil, err + } + + return nil, nil, nil +} + +func (o *appBuilder) Revoke(ctx context.Context, grant *v2.Grant) (annotations.Annotations, error) { + appID := grant.Entitlement.Resource.Id.Resource + principalId := grant.Principal.Id + + if principalId.ResourceType != userResourceType.Id { + return nil, status.Errorf(codes.InvalidArgument, "only users can have app access revoked") + } + + err := o.client.RevokeAppAccess(ctx, appID, principalId.Resource) + if err != nil { + return nil, err + } + + return nil, nil +} + +func newAppBuilder(client *client.Client) *appBuilder { + return &appBuilder{ + client: client, + } +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index dab760f9..2e06967a 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -25,6 +25,7 @@ func (d *Demo) EventFeeds(ctx context.Context) []connectorbuilder.EventFeed { newRoleEventFeed(d.client), newProjectEventFeed(d.client), newScopedRoleEventFeed(d.client), + newAppEventFeed(d.client), } } @@ -32,6 +33,7 @@ func (d *Demo) EventFeeds(ctx context.Context) []connectorbuilder.EventFeed { func (d *Demo) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncerV2 { return []connectorbuilder.ResourceSyncerV2{ newUserBuilder(d.client), + newAppBuilder(d.client), newGroupBuilder(d.client), newRoleBuilder(d.client), newScopedRoleBuilder(d.client), diff --git a/pkg/connector/events.go b/pkg/connector/events.go index 92274e97..2ac3e2b9 100644 --- a/pkg/connector/events.go +++ b/pkg/connector/events.go @@ -318,3 +318,65 @@ func newScopedRoleEventFeed(client *client.Client) *scopedRoleEventFeed { client: client, } } + +// appEventFeed + +type appEventFeed struct { + client *client.Client +} + +var _ connectorbuilder.EventFeed = (*appEventFeed)(nil) + +func (d *appEventFeed) EventFeedMetadata(ctx context.Context) *v2.EventFeedMetadata { + return &v2.EventFeedMetadata{ + Id: "app-change-feed", + SupportedEventTypes: []v2.EventType{v2.EventType_EVENT_TYPE_RESOURCE_CHANGE}, + } +} + +func (d *appEventFeed) ListEvents( + ctx context.Context, + earliestEvent *timestamppb.Timestamp, + sToken *pagination.StreamToken, +) ([]*v2.Event, *pagination.StreamState, annotations.Annotations, error) { + events := []*v2.Event{} + + var occurredAt time.Time + if earliestEvent != nil { + occurredAt = earliestEvent.AsTime() + } + apps, nextPageToken, err := d.client.ListAppsByUpdatedAt(ctx, occurredAt, sToken) + if err != nil { + return nil, nil, nil, err + } + + for _, app := range apps { + appRes, err := appResource(app, nil) + if err != nil { + return nil, nil, nil, err + } + events = append(events, &v2.Event{ + Id: fmt.Sprintf("app-change-%s-%s", app.UpdatedAt.Format(time.RFC3339Nano), app.Id), + OccurredAt: timestamppb.New(app.UpdatedAt), + Event: &v2.Event_ResourceChangeEvent{ + ResourceChangeEvent: &v2.ResourceChangeEvent{ + ResourceId: appRes.GetId(), + ParentResourceId: appRes.GetParentResourceId(), + }, + }, + }) + } + + streamState := &pagination.StreamState{ + Cursor: nextPageToken, + HasMore: nextPageToken != "", + } + + return events, streamState, nil, nil +} + +func newAppEventFeed(client *client.Client) *appEventFeed { + return &appEventFeed{ + client: client, + } +} diff --git a/pkg/connector/groups.go b/pkg/connector/groups.go index d80cfc27..dd1b9412 100644 --- a/pkg/connector/groups.go +++ b/pkg/connector/groups.go @@ -59,9 +59,16 @@ func (o *groupBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId return nil, nil, err } + // Build groupID -> appResourceID lookup for child groups + groupToApp := o.buildGroupToAppMapping(ctx) + var ret []*v2.Resource for _, g := range groups { - group, err := groupResource(g, parentResourceID) + parent := parentResourceID + if appResID, ok := groupToApp[g.Id]; ok { + parent = appResID + } + group, err := groupResource(g, parent) if err != nil { return nil, nil, err } @@ -76,7 +83,14 @@ func (o *groupBuilder) Get(ctx context.Context, resourceId *v2.ResourceId, paren if err != nil { return nil, nil, err } - resource, err := groupResource(group, parentResourceId) + + parent := parentResourceId + groupToApp := o.buildGroupToAppMapping(ctx) + if appResID, ok := groupToApp[group.Id]; ok { + parent = appResID + } + + resource, err := groupResource(group, parent) if err != nil { return nil, nil, err } @@ -354,6 +368,25 @@ func (o *groupBuilder) ResourceActions(ctx context.Context, registry actions.Act return nil } +// buildGroupToAppMapping builds a lookup from child group IDs to their parent app resource IDs. +func (o *groupBuilder) buildGroupToAppMapping(ctx context.Context) map[string]*v2.ResourceId { + result := make(map[string]*v2.ResourceId) + apps, err := o.client.ListApps(ctx) + if err != nil { + return result + } + for _, a := range apps { + appResID := &v2.ResourceId{ + ResourceType: appResourceType.Id, + Resource: a.Id, + } + for _, childGroupID := range a.ChildGroups { + result[childGroupID] = appResID + } + } + return result +} + func newGroupBuilder(client *client.Client) *groupBuilder { return &groupBuilder{ client: client, diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index 2422732a..75374571 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -34,6 +34,13 @@ var scopedRoleResourceType = &v2.ResourceType{ Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SCOPE_BINDING}, } +// The app resource type is for all app objects from the database. +var appResourceType = &v2.ResourceType{ + Id: "app", + DisplayName: "App", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_APP}, +} + // The project resource type is for all project objects from the database // Projects don't match any of the well-known resource traits. var projectResourceType = &v2.ResourceType{