// Command tag-missions runs the WS7 Phase 5a tenant mission tagging backfill. For each organization it // classifies that tenant's candidates (headline plus skill names) and requirements (title plus labor // category plus required skill names) with the pure deterministic missionclass classifier, and upserts // structured mission tags into candidate_mission_tags and requirement_mission_tags. Those tags are the // structured signal the Phase 5 retrieval upgrade and the Phase 6 Command Center read, replacing the WS6 // text only mission filter. It is a one shot job and a cron candidate like staffing-proactive-sync, and it // is idempotent and safe to rerun: every upsert refreshes in place rather than duplicating. // // Mission tags are reference data, NEVER a scoring input, and the pass never reaches the eeo schema. The // classifier is the SAME one the global federalsignal pass uses, so a tenant's SIGINT analyst gets a SIGINT // tag from its headline and a COMSEC tag from a COMSEC skill; the curated WS2 alias layer adds synonyms // beyond a literal keyword. // // It lists organization ids and reads the global alias pairs through the migrator role (the alias tables // are global reference data). Every TAG WRITE, and the candidate and requirement reads it classifies, run // through the NOBYPASSRLS application role inside each org's RunInTenantTx, so Postgres Row Level Security // is exercised on every row and one tenant's tags can never read or write another's. Pass -org to limit the // run to a single organization id. // // Env (server side only): MM_DATABASE_URL (the app DSN, NOBYPASSRLS, required), MM_MIGRATOR_DATABASE_URL // (the migrator DSN, BYPASSRLS, used ONLY to enumerate org ids and read the global alias pairs; falls back // to MM_DATABASE_URL). // // Build and run FIPS validated: CGO_ENABLED=0 GOFIPS140=v1.0.0 go build, run with GODEBUG=fips140=on. package main import ( "context" "crypto/fips140" "crypto/pbkdf2" "crypto/sha256" "flag" "log" "os" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/missionworkforce/missionmatch/internal/modules/federalsignal" "github.com/missionworkforce/missionmatch/internal/platform/database" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) func main() { orgFlag := flag.String("org", "", "limit the backfill to a single organization id (default: all orgs)") flag.Parse() // FIPS self test: derive one PBKDF2 key at the production cost under the active crypto module and fail // closed if it errors, so a build that dropped the validated module is caught at boot, not mid run. This // is the same in build FIPS gate the api and missionclass-sync binaries use. if err := fipsSelfTest(); err != nil { log.Fatalf("tag-missions: FIPS self test (PBKDF2 HMAC SHA 256) failed: %v", err) } log.Printf("tag-missions: fips_enabled=%v module=%s", fips140.Enabled(), fips140.Version()) appDSN := strings.TrimSpace(os.Getenv("MM_DATABASE_URL")) if appDSN == "" { log.Fatal("tag-missions: MM_DATABASE_URL (the app DSN) is required") } migDSN := firstNonEmpty(os.Getenv("MM_MIGRATOR_DATABASE_URL"), appDSN) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() // App pool: the tenant tag reads and writes run here, under RLS, never the migrator bypass. appPool, err := database.NewPool(ctx, appDSN) if err != nil { log.Fatalf("tag-missions: app pool: %v", err) } defer appPool.Close() // Fail closed unless this is the NOBYPASSRLS app role, so the backfill can never bypass tenant RLS. if err := database.AssertRole(ctx, appPool, database.AppRoleName()); err != nil { log.Fatalf("tag-missions: app pool role check: %v", err) } appDB := tenant.New(appPool) // Migrator pool: enumerate org ids and read the global alias pairs ONLY. No tenant rows are written here. migPool, err := pgxpool.New(ctx, migDSN) if err != nil { log.Fatalf("tag-missions: migrator pool: %v", err) } defer migPool.Close() if err := database.AssertMigratorRole(ctx, migPool); err != nil { log.Fatalf("tag-missions: %v", err) } // Build the AliasIndex ONCE from the global alias pairs (read via the migrator) and share it across every // org, so the alias compile happens one time, not per org. The reader is the same MissionAliasPairs query // the global pass uses, so both passes classify against an identical curated alias layer. idx, err := federalsignal.BuildAliasIndex(ctx, federalsignal.NewRepo(migPool)) if err != nil { log.Fatalf("tag-missions: build alias index: %v", err) } orgIDs, err := listOrgs(ctx, migPool, *orgFlag) if err != nil { log.Fatalf("tag-missions: list orgs: %v", err) } if len(orgIDs) == 0 { log.Print("tag-missions: no organizations to process") return } svc := federalsignal.NewTenantTagService(federalsignal.NewTenantTagRepo(appDB), idx) var totalCandTags, totalReqTags int for _, orgID := range orgIDs { orgCtx := tenant.WithOrg(ctx, orgID.String()) cRes, cerr := svc.TagCandidatesForOrg(orgCtx) if cerr != nil { log.Fatalf("tag-missions: org %s tag candidates: %v", orgID, cerr) } rRes, rerr := svc.TagRequirementsForOrg(orgCtx) if rerr != nil { log.Fatalf("tag-missions: org %s tag requirements: %v", orgID, rerr) } log.Printf("tag-missions: org %s scanned %d candidates wrote %d candidate tags, scanned %d requirements wrote %d requirement tags", orgID, cRes.CandidatesScanned, cRes.CandidateTags, rRes.RequirementsScanned, rRes.RequirementTags) totalCandTags += cRes.CandidateTags totalReqTags += rRes.RequirementTags } log.Printf("tag-missions: DONE, %d org(s), wrote %d candidate mission tags and %d requirement mission tags", len(orgIDs), totalCandTags, totalReqTags) } // listOrgs returns the organization ids to process. It reads them through the migrator role (which sees // every org) so one run covers the cluster; the per org tagging then runs under the app role and RLS. A // -org value restricts the run to a single org for a targeted backfill. func listOrgs(ctx context.Context, migPool *pgxpool.Pool, only string) ([]uuid.UUID, error) { if only != "" { id, err := uuid.Parse(strings.TrimSpace(only)) if err != nil { return nil, err } return []uuid.UUID{id}, nil } return dbgen.New(migPool).ListAllOrganizationIDs(ctx) } // fipsSelfTest derives one PBKDF2 key at the production iteration count under the active crypto module. It // is the in build FIPS gate, identical to the api and missionclass-sync binaries, so this backfill binary // is held to the same validated crypto posture. func fipsSelfTest() error { _, err := pbkdf2.Key(sha256.New, "missionmatch fips self test", []byte("self test salt"), security.PBKDF2Iterations, 32) return err } func firstNonEmpty(vals ...string) string { for _, v := range vals { if s := strings.TrimSpace(v); s != "" { return s } } return "" }