What it is
IBM MaaS360 is the MDM that pushes apps to our mobile devices. It knows every app we manage, every uploaded version, how big each one is, and which device groups get it. It is also the only place that knows, and getting an answer out of it means an admin login and a lot of clicking.
The catalog is a read-only copy of that information that anyone on the team can search:
- a Go program that reads the app list from the MaaS360 Web Services API once a day and writes it into Postgres
- a small Go API (chi + pgx) that serves it as JSON with filtering, sorting and paging
- a React + TypeScript SPA with one table component, where every view is a URL you can paste to someone
It also carries two lists MaaS360 has never heard of, the ATAK/WinTAK plugins and the sensors we support. Those are curated by hand.
MaaS360 API ──> seedgen ──> apps_seed.sql ──> psql ──> Postgres
(daily job) │
▼
Browser ──> nginx (SPA) ──/api/──> Go API ────────────────┘Why
The questions are simple and the console is not. "What version of this app are we on?" "Which tenants get it?" "How big is the APK?" Each one is a few seconds of work if the data is in a table, and a few minutes in the MDM console, for the handful of people who have an account there.
Most people who ask should not have MDM admin access. A read-only copy can go to a much wider audience than the system that can wipe a phone.
I wanted it scriptable. A JSON API with an OpenAPI description means the next tool that needs the app list does not have to learn MaaS360 at all.
The sync writes SQL, not rows
seedgen does not connect to the database. It reads MaaS360 and writes one SQL file. A second step applies it:
echo "[seed-job] generating seed from MaaS360 ..."
seedgen -out "$SEED"
echo "[seed-job] applying seed to $DB_NAME (schema $DB_SCHEMA) ..."
psql "$CONN" --set ON_ERROR_STOP=on \
-c "SET search_path TO \"$DB_SCHEMA\";" \
-f "$SEED"That split paid for itself several times:
- The file is a diffable artifact. When a sync changes something I did not expect,
git diffon the generated seed shows exactly what MaaS360 started saying differently. - The generator is testable without a database or an MDM. The tests hand
renderSQLa few rows and check the output for the fragments that matter. - Every statement is idempotent (
ON CONFLICT,WHERE NOT EXISTS), so running the job twice, or re-applying an old file by hand, is harmless. - If the fetch returns nothing usable, the generator refuses to write a file at all, so a bad day at the API cannot empty the catalog.
In production the two steps run as a scheduled container job: a small Alpine image with the seedgen binary and a psql client.
Talking to MaaS360
Authentication is an XML POST that returns a token. The token then goes in an Authorization header with its own scheme:
req.Header.Set("Authorization", fmt.Sprintf(`MaaS token="%s"`, token))
req.Header.Set("Accept", "application/json")The registered API app has an access key, but the key only identifies the client. There is no app-only grant. Every session is an administrator's session, so the job needs a real admin username and password. More on that at the end.
The catalog comes from Search for Apps (v2). It requires an appId filter and does partial matching, so the filter that returns everything is a single dot, because every reverse-DNS app id has one:
// appId is the only required search filter; "." partial-matches every
// reverse-DNS app id (com.*, org.*, me.*, ...), i.e. the whole catalog.
appID := flag.String("app-id", ".", "partial appId filter (required by the API)")The JSON changes shape
The API is XML underneath with JSON on top, and it shows. A list with one element comes back as a bare object and not an array. A version is a string on some rows and a number on others. An app with no distributions has "distributions": "", and one with distributions has an object there.
Every field like that is decoded as json.RawMessage and looked at before it is trusted:
// decodeApps handles "app" being either an array or a single object.
func decodeApps(raw json.RawMessage) ([]app, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || string(trimmed) == "null" {
return nil, nil
}
if trimmed[0] == '[' {
var apps []app
return apps, json.Unmarshal(trimmed, &apps)
}
var single app
if err := json.Unmarshal(trimmed, &single); err != nil {
return nil, err
}
return []app{single}, nil
}Two more in the same family: a search that matches nothing returns a 404 and not an empty list, so the paging loop treats 404 as "done". And a failed login can come back as HTTP 200 with an error code in the body, so the auth code parses the body first and only trusts a response that actually has a token in it.
Three things the app list got wrong
These are the parts worth reading if you ever sync from MaaS360 yourself.
An app is not one row
The first version asked App Details (v2) for each app, addressed by appId and appType, and took the version from the answer. One app showed a version that was months old. The newer one was sitting in the console the whole time.
MaaS360 keeps a separate catalog entry for every uploaded version, and the search returns a row per app and distribution group. So appId + appType is not a key. Ask for details with only those two and you get one of the matching entries, picked for you, and the rest are unreachable.
The details call takes an appVersion parameter, and that is what makes an entry addressable. The generator now keys on all three:
// Key on the version too: entries sharing appId+appType differ only by
// version, and each carries its own distribution list.
version := strings.TrimSpace(rawToString(a.AppVersion))
k := key{a.AppID, a.AppType, version}Versions, upload dates and sizes come straight from the search rows, which already carry them. Against the live catalog that one change took the release count from 12 to 26 and turned up seven app-to-tenant pairs that had been missing, with nothing lost.
Scope belongs to a version, not an app
Our tenants map to device groups named Group: T<n>. An app distributed to one or more of those groups is scoped to those tenants. An app that only goes to something like All Devices is global.
// tenantGroupRe matches a tenant-scoped distribution group such as
// "Group: T7 EUDS" and captures the tenant number.
var tenantGroupRe = regexp.MustCompile(`(?i)Group:\s*T(\d+)\b`)Because each version is its own entry with its own distribution list, two versions of the same app can go to different tenants. That is a normal state during a rollout: the new build goes to one tenant while everyone else stays on the old one. An app-level "which tenants" answer flattens that into something misleading.
So the schema keeps both grains. application_tenants is the union, and it is what the list filter uses. app_release_tenants is the truth, and the app detail page shows it per version.
One more trap here: the search rows have a groupName field that looks like the distribution group. It is the "Available for" setting, which is a different thing. Scope comes from the distribution list only.
A sync that only inserts is not a mirror
For months the seed only added rows. Then an app was moved from All Devices to a single tenant group, and the catalog showed both. Being wrong in the "too many people can get this" direction is the bad kind of wrong for this data.
The generated file now ends the distributions section by deleting whatever MaaS360 no longer reports:
-- Prune distributions no longer present in MaaS360
DELETE FROM app_distributions x
USING applications a
JOIN (VALUES
('Example App', 'android'),
...
) AS s(name, platform) ON a.name = s.name AND a.platform = s.platform
WHERE x.application_id = a.id
AND NOT EXISTS (
SELECT 1 FROM (VALUES
('Example App', 'android', 'Group: T1 EUDs'),
...
) AS d(name, platform, target)
WHERE d.name = a.name AND d.platform = a.platform AND d.target = x.target
);The first VALUES list is the important part. It only names apps whose details were fetched completely during this run. If one details call times out, that app is left out of the list and keeps the rows it had yesterday. A delete driven by a partial read is how a transient API error becomes missing data.
Store apps have a version too
Public store apps have nothing uploaded, so they get no release rows, and at first they showed no version at all. MaaS360 does report the store's current version for them. It is not a release (no file, no size, and the date on it is when the app was added to the catalog), so it lives in one column on the app, and the API falls back to it:
COALESCE((SELECT r.version FROM app_releases r WHERE r.app_id = applications.id
ORDER BY r.release_date DESC, r.id DESC LIMIT 1), store_version) AS current_versionThe API
Every list endpoint takes the same four things:
GET /apps?limit=50&offset=0&sort=platform,-name&filter[name]=tak&filter[tenant]=tenant7and returns the same envelope:
{ "data": [ ... ], "pagination": { "limit": 50, "offset": 0, "total": 3 } }The sort and filter column names in the URL never reach SQL directly. Each resource declares the names it accepts and what they map to, and anything else is dropped:
appsSortable = map[string]string{
"name": "name",
"platform": "platform",
"version_count": "version_count",
"current_version": "current_version",
}
appsFilterable = map[string]string{
"name": "name",
"platform": "platform",
"all_tenants": "all_tenants",
"tenant": "tenant", // virtual: filters by tenant scope
}Values always go through placeholders. Each resource also says how a filter compares: ILIKE for names, equality for enums, a parsed boolean for flags.
tenant is not a column. It is pulled out before the WHERE clause is built and turned into "global, or explicitly linked to this tenant". An unknown tenant name matches nothing, which is the safe way for that to fail.
The API is read-only, and that is enforced where it is cheapest: the router has no write routes, and CORS allows GET and OPTIONS only.
Docs that cannot drift
The OpenAPI 3.1 spec is embedded in the API binary and served at /openapi.yaml:
//go:embed openapi.yaml
var spec []byteThe web app renders it with Scalar, and it fetches the document from the API it is talking to. It does not bundle a copy. The reference page always describes the server that is actually running.
One thing that cost me a while: @scalar/api-reference-react does not import its own stylesheet from its JavaScript entry point, so without an explicit import "@scalar/api-reference-react/style.css" the page renders as unstyled HTML with no error anywhere.
The frontend
There are three list pages (apps, plugins, sensors) and they are all the same component. A page supplies column definitions and a list of filters, and DataTable does the rest with TanStack Table in manual mode. Sorting, filtering and paging all happen in Postgres, and the table only ever holds the page it is showing.
The part I would repeat on any project: the table has no state of its own. Sort order, filters, page offset and hidden columns all live in the query string.
/apps?f.platform=android&f.tenant=tenant7&sort=-current_version&hide=id,created_atThat gets you a working back button and reload for free. Mostly it means the answer to "which Android apps does tenant 7 get?" is a link you can drop in chat, and not a set of instructions for clicking.
Typing in a filter fires a request per keystroke, and the responses can come back out of order. A counter handles it. Each request takes a number, and only the latest one is allowed to touch state:
const my = ++reqId.current;
fetchList<T>(resource, q)
.then((env) => {
if (my !== reqId.current) return;
setRows(env.data);
setTotal(env.pagination.total);
})There is also a column picker and a CSV export of whatever is on screen, because sooner or later every table like this ends up in a spreadsheet.
Sign-in is Entra ID through MSAL. The whole app sits behind one guard component that redirects to the Microsoft login page.
Running it
Two containers run side by side: nginx serving the built SPA, and the Go API. nginx proxies /api/ to the API on localhost:
location /api/ {
proxy_pass http://127.0.0.1:8080/;
}
location / {
try_files $uri $uri/ /index.html;
}The SPA only ever calls the relative path /api/.... In development Vite proxies that to the API, and in production nginx does. One build works in both places, and the browser never makes a cross-origin request.




No comments yet