package federalsignal // Production TenantTagRepo: the DB backed adapter that makes every read and every write of the Phase 5a // tenant tagging service run inside RunInTenantTx as the application role. RLS is therefore exercised on // every row, the org id is set transaction locally from the validated context GUC, and a child row can // never reference a parent in another tenant (the composite FK). This adapter never touches the migrator // bypass: it holds a *tenant.DB whose pool authenticates as the NOBYPASSRLS application role. import ( "context" "fmt" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" "github.com/missionworkforce/missionmatch/internal/platform/missionclass" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // tenantTagRepo is the production TenantTagRepo over a tenant scoped application DB. type tenantTagRepo struct{ db *tenant.DB } // NewTenantTagRepo builds the production TenantTagRepo. db MUST be a tenant.DB whose pool authenticates as // the application role, so RunInTenantTx is the only path to the rows and RLS cannot be bypassed. func NewTenantTagRepo(db *tenant.DB) TenantTagRepo { return &tenantTagRepo{db: db} } func (r *tenantTagRepo) ListCandidatesForTagging(ctx context.Context) ([]dbgen.ListCandidatesForMissionTaggingRow, error) { var out []dbgen.ListCandidatesForMissionTaggingRow err := r.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var e error out, e = dbgen.New(tx).ListCandidatesForMissionTagging(ctx) return e }) return out, err } func (r *tenantTagRepo) ListRequirementsForTagging(ctx context.Context) ([]dbgen.ListRequirementsForMissionTaggingRow, error) { var out []dbgen.ListRequirementsForMissionTaggingRow err := r.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var e error out, e = dbgen.New(tx).ListRequirementsForMissionTagging(ctx) return e }) return out, err } // UpsertCandidateMissionTags writes every tag inside ONE tenant transaction as the application role, so // the whole org's candidate tags commit or roll back together and RLS scopes each insert to the org GUC. func (r *tenantTagRepo) UpsertCandidateMissionTags(ctx context.Context, tags []dbgen.UpsertCandidateMissionTagParams) error { if len(tags) == 0 { return nil } return r.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { q := dbgen.New(tx) for _, t := range tags { if err := q.UpsertCandidateMissionTag(ctx, t); err != nil { return err } } return nil }) } // UpsertRequirementMissionTags writes every requirement tag inside one tenant transaction as the // application role, same isolation guarantees as the candidate path. func (r *tenantTagRepo) UpsertRequirementMissionTags(ctx context.Context, tags []dbgen.UpsertRequirementMissionTagParams) error { if len(tags) == 0 { return nil } return r.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { q := dbgen.New(tx) for _, t := range tags { if err := q.UpsertRequirementMissionTag(ctx, t); err != nil { return err } } return nil }) } // AliasPairReader is the single method the backfill needs to seed the AliasIndex. The production Repo (from // NewRepo) satisfies it, so the backfill reuses the same MissionAliasPairs read the global pass uses, which // means the tenant pass and the global pass classify against an identical curated alias layer. type AliasPairReader interface { AliasPairs(ctx context.Context) (map[string]string, error) } // BuildAliasIndex loads the global (phrase, code) alias pairs through the given reader and compiles them // into a missionclass.AliasIndex ONCE. The backfill builds the index a single time and shares it across // every org, because the alias layer is global reference data the application role may read. func BuildAliasIndex(ctx context.Context, r AliasPairReader) (missionclass.AliasIndex, error) { pairs, err := r.AliasPairs(ctx) if err != nil { return missionclass.AliasIndex{}, fmt.Errorf("federalsignal: load alias pairs: %w", err) } return missionclass.NewAliasIndex(pairs), nil }