Website: Add android proxy endpoints (#28267)

Related to: https://github.com/fleetdm/fleet/issues/26270

Changes:
- Added a new database model: `AndroidEnterprise`
- Added one new website dependency: `googleapis@148.0.0`
- Added `android-proxy/create-android-signup-url`: an endpoint that
returns a signup url used to grant access to Fleet's Android MDM
integration.
- Added `android-proxy/create-android-enterprise`: An endpoint that
creates an Android enterprise for a Fleet server
- Added `android-proxy/create-android-enrollment-token`: An endpoint
that returns an enrollment token for an Android enterprise
- Added `android-proxy/modify-android-policies`: An endpoint used to
update policies of an Android enterprise
- Added `android-proxy/delete-one-android-enterprise`: an endpoint that
deletes an Android enterprise

---------

Co-authored-by: Victor Lyuboslavsky <victor@fleetdm.com>
This commit is contained in:
Eric
2025-06-12 13:23:49 -05:00
committed by GitHub
co-authored by Victor Lyuboslavsky
parent 4d0a8debd6
commit 4272df375a
12 changed files with 590 additions and 1 deletions
+1
View File
@@ -52,6 +52,7 @@
"Platform": true,
"AdCampaign": true,
"MicrosoftComplianceTenant": true,
"AndroidEnterprise": true,
// …and any others.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
},
@@ -0,0 +1,78 @@
module.exports = {
friendlyName: 'Create android enrollment token',
description: 'Creates and returns an enrollment token for an Android enterprise',
inputs: {
androidEnterpriseId: {
type: 'string',
required: true,
},
},
exits: {
},
fn: async function ({androidEnterpriseId}) {
// Extract fleetServerSecret from the Authorization header
let authHeader = this.req.get('authorization');
let fleetServerSecret;
if (authHeader && authHeader.startsWith('Bearer')) {
fleetServerSecret = authHeader.replace('Bearer', '').trim();
} else {
return this.res.unauthorized('Authorization header with Bearer token is required');
}
// Authenticate this request
let thisAndroidEnterprise = await AndroidEnterprise.findOne({
androidEnterpriseId: androidEnterpriseId,
});
// Return a 404 response if no records are found.
if(!thisAndroidEnterprise) {
return this.res.notFound();
}
// Return an unauthorized response if the provided secret does not match.
if(thisAndroidEnterprise.fleetServerSecret !== fleetServerSecret) {
return this.res.unauthorized();
}
let newEnrollmentToken = await sails.helpers.flow.build(async ()=>{
let { google } = require('googleapis');
let androidmanagement = google.androidmanagement('v1');
let googleAuth = new google.auth.GoogleAuth({
scopes: ['https://www.googleapis.com/auth/androidmanagement'],
credentials: {
client_email: sails.config.custom.androidEnterpriseServiceAccountEmailAddress,// eslint-disable-line camelcase
private_key: sails.config.custom.androidEnterpriseServiceAccountPrivateKey,// eslint-disable-line camelcase
},
});
// Acquire the google auth client, and bind it to all future calls
let authClient = await googleAuth.getClient();
google.options({auth: authClient});
// [?]: https://googleapis.dev/nodejs/googleapis/latest/androidmanagement/classes/Resource$Enterprises$Enrollmenttokens.html#create
let enrollmentTokenCreateResponse = await androidmanagement.enterprises.enrollmentTokens.create({
parent: `enterprises/${androidEnterpriseId}`,
requestBody: this.req.body,
});
return enrollmentTokenCreateResponse.data;
}).intercept((err)=>{
return new Error(`When attempting to create an enrollment token for an Android enterprise (${androidEnterpriseId}), an error occurred. Error: ${err}`);
});
return newEnrollmentToken;
}
};
@@ -0,0 +1,168 @@
module.exports = {
friendlyName: 'Create android enterprise',
description: 'Creates a new Android enterprise from a request from a Fleet instance.',
inputs: {
signupUrlName: {
type: 'string',
required: true,
},
enterpriseToken: {
type: 'string',
required: true,
},
fleetLicenseKey: {
type: 'string',
},
pubsubPushUrl: {
type: 'string',
required: true,
},
enterprise: {
type: {},
required: true,
moreInfoUrl: ''
}
},
exits: {
success: { description: 'An android enterprise was successfully created' },
enterpriseAlreadyExists: { description: 'An android enterprise already exists for this Fleet instance.', statusCode: 409 },
},
fn: async function ({signupUrlName, enterpriseToken, fleetLicenseKey, pubsubPushUrl, enterprise}) {
// Parse the Fleet server url from the origin header.
let fleetServerUrl = this.req.get('Origin');
if(!fleetServerUrl){
return this.res.badRequest();
}
// Check the database for a record of this enterprise.
let connectionforThisInstanceExists = await AndroidEnterprise.findOne({fleetServerUrl: fleetServerUrl});
// If this request came from a Fleet instance that already has an enterprise set up, return an error.
if(connectionforThisInstanceExists) {
throw 'enterpriseAlreadyExists';
}
// Generate a uuid to use for the pubsub topic name for this Android enterprise.
let newPubSubTopicName = 'a' + sails.helpers.strings.uuid();// Google requires that topic names start with a letter, so we'll preprend an 'a' to the generated uuid.
// Build the full pubsub topic name.
let fullPubSubTopicName = `projects/${sails.config.custom.androidEnterpriseProjectId}/topics/${newPubSubTopicName}`;
enterprise.pubsubTopic = fullPubSubTopicName;
let newSubscriptionName = `projects/${sails.config.custom.androidEnterpriseProjectId}/subscriptions/${newPubSubTopicName}`;
// Complete the setup of the new Android enterprise.
// Note: We're using sails.helpers.flow.build here to handle any errors that occurr using google's node library.
let newEnterprise = await sails.helpers.flow.build(async ()=>{
let { google } = require('googleapis');
let androidmanagement = google.androidmanagement('v1');
let googleAuth = new google.auth.GoogleAuth({
scopes: [
'https://www.googleapis.com/auth/androidmanagement',
'https://www.googleapis.com/auth/pubsub'
],
credentials: {
client_email: sails.config.custom.androidEnterpriseServiceAccountEmailAddress,// eslint-disable-line camelcase
private_key: sails.config.custom.androidEnterpriseServiceAccountPrivateKey,// eslint-disable-line camelcase
},
});
// Acquire the google auth client, and bind it to all future calls
let authClient = await googleAuth.getClient();
google.options({auth: authClient});
let pubsub = google.pubsub({version: 'v1'});
// Create a new pubsub topic for this enterprise.
// [?]: https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics/create
await pubsub.projects.topics.create({
name: fullPubSubTopicName,
requestBody: {
messageRetentionDuration: '86400s'// 24 hours
}
});
// [?]: https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics/getIamPolicy
// Retrieve the IAM policy for the created pubsub topic.
let getIamPolicyResponse = await pubsub.projects.topics.getIamPolicy({
resource: fullPubSubTopicName,
});
let newPubSubTopicIamPolicy = getIamPolicyResponse.data;
// Grand Android device policy the right to publish
// See: https://developers.google.com/android/management/notifications
// Default the policy bindings to an empty array if it is not set.
newPubSubTopicIamPolicy.bindings = newPubSubTopicIamPolicy.bindings || [];
// Add the Fleet android MDM service account to the policy bindings.
newPubSubTopicIamPolicy.bindings.push({
role: 'roles/pubsub.publisher',
members: ['serviceAccount:android-cloud-policy@system.gserviceaccount.com']
});
// Update the pubsub topic's IAM policy
// [?]: https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics/setIamPolicy
await pubsub.projects.topics.setIamPolicy({
resource: fullPubSubTopicName,
requestBody: {
policy: newPubSubTopicIamPolicy
}
});
// Create a new subscription for the created pubsub topic.
// [?]: https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions/create
await pubsub.projects.subscriptions.create({
name: newSubscriptionName,
requestBody: {
topic: fullPubSubTopicName,
ackDeadlineSeconds: 60,
messageRetentionDuration: '86400s',// 24 hours
expirationPolicy: {}, // never expire, so that customers can enable Android but actually enroll devices months later
pushConfig: {
pushEndpoint: pubsubPushUrl// Use the pubsubPushUrl provided by the Fleet server.
}
}
});
// Now create the new enterprise for this Fleet server.
// [?]: https://googleapis.dev/nodejs/googleapis/latest/androidmanagement/classes/Resource$Enterprises.html#create
let createEnterpriseResponse = await androidmanagement.enterprises.create({
agreementAccepted: true,
enterpriseToken: enterpriseToken,
projectId: sails.config.custom.androidEnterpriseProjectId,
signupUrlName: signupUrlName,
requestBody: enterprise,
});
return createEnterpriseResponse.data;
}).intercept((err)=>{
return new Error(`When attempting to create a new Android enterprise, an error occurred. Error: ${require('util').inspect(err)}`);
});
let newAndroidEnterpriseId = newEnterprise.name;
// Create a new fleetServerSecret for this Fleet server. This will be included in the response body and will be required in all subsequent requests to Android proxy endpoints.
let newFleetServerSecret = await sails.helpers.strings.random.with({len: 30});
// Update the database record to include details about the created enterprise.
await AndroidEnterprise.create({
fleetServerUrl: fleetServerUrl,
fleetLicenseKey: fleetLicenseKey,
androidEnterpriseId: newAndroidEnterpriseId.replace(/enterprises\//, ''),// Remove the /enterprises prefix from the androidEnterpriseId that we save in the website database.
pubsubTopicName: fullPubSubTopicName,
pubsubSubscriptionName: newSubscriptionName,
fleetServerSecret: newFleetServerSecret,
});
return {
name: newAndroidEnterpriseId,
fleetServerSecret: newFleetServerSecret,
};
}
};
@@ -0,0 +1,78 @@
module.exports = {
friendlyName: 'Create android signup url',
description: 'Creates and returns a signup URL for an android enterprise.',
inputs: {
callbackUrl: {
type: 'string',
required: true,
}
},
exits: {
success: { description: 'A signup URL has been sent to the requesting Fleet server.'},
enterpriseAlreadyExists: { description: 'An Android enterprise already exists for this Fleet instance.', statusCode: 409 },
},
fn: async function ({ callbackUrl }) {
// Parse the Fleet server url from the origin header.
let fleetServerUrl = this.req.get('Origin');
if(!fleetServerUrl){
return this.res.badRequest();
}
// Check the database for an existing record for this Fleet server.
let connectionforThisInstanceExists = await AndroidEnterprise.findOne({fleetServerUrl: fleetServerUrl});
if(connectionforThisInstanceExists){
throw 'enterpriseAlreadyExists';
}
// Get a signup url for this Android enterprise.
// Note: We're using sails.helpers.flow.build here to handle any errors that occurr using google's node library.
let signupUrl = await sails.helpers.flow.build(async ()=>{
let { google } = require('googleapis');
let androidmanagement = google.androidmanagement('v1');
let googleAuth = new google.auth.GoogleAuth({
scopes: ['https://www.googleapis.com/auth/androidmanagement'],
credentials: {
client_email: sails.config.custom.androidEnterpriseServiceAccountEmailAddress,// eslint-disable-line camelcase
private_key: sails.config.custom.androidEnterpriseServiceAccountPrivateKey,// eslint-disable-line camelcase
},
});
// Acquire the google auth client, and bind it to all future calls
let authClient = await googleAuth.getClient();
google.options({auth: authClient});
// [?] https://googleapis.dev/nodejs/googleapis/latest/androidmanagement/classes/Resource$Signupurls.html#create
let createSignupUrlResponse = await androidmanagement.signupUrls.create({
// The callback URL that the admin will be redirected to after successfully creating an enterprise. Before redirecting there the system will add a query parameter to this URL named enterpriseToken which will contain an opaque token to be used for the create enterprise request. The URL will be parsed then reformatted in order to add the enterpriseToken parameter, so there may be some minor formatting changes.
callbackUrl: callbackUrl,
// The ID of the Google Cloud Platform project which will own the enterprise.
projectId: sails.config.custom.androidEnterpriseProjectId,
});
return createSignupUrlResponse.data;
}).intercept((err)=>{
return new Error(`When attempting to create a singup url for a new Android enterprise, an error occurred. Error: ${err}`);
});
return {
url: signupUrl.url,
name: signupUrl.name,
};
}
};
@@ -0,0 +1,96 @@
module.exports = {
friendlyName: 'Delete one android enterprise',
description: 'Deletes an android enterprise and the associated database record.',
inputs: {
androidEnterpriseId: {
type: 'string',
required: true,
},
},
exits: {
success: { description: 'An Android enterprise was successfully deleted.' }
},
fn: async function ({androidEnterpriseId}) {
// Extract fleetServerSecret from the Authorization header
let authHeader = this.req.get('authorization');
let fleetServerSecret;
if (authHeader && authHeader.startsWith('Bearer')) {
fleetServerSecret = authHeader.replace('Bearer', '').trim();
} else {
return this.res.unauthorized('Authorization header with Bearer token is required');
}
// Look up the database record for this Android enterprise
let thisAndroidEnterprise = await AndroidEnterprise.findOne({
androidEnterpriseId: androidEnterpriseId,
});
// Return a 404 response if no records are found.
if(!thisAndroidEnterprise) {
return this.res.notFound();
}
// Return an unauthorized response if the provided secret does not match.
if(thisAndroidEnterprise.fleetServerSecret !== fleetServerSecret) {
return this.res.unauthorized();
}
// Delete the Android enterprise
// Note: We're using sails.helpers.flow.build here to handle any errors that occurr using google's node library.
await sails.helpers.flow.build(async ()=>{
let { google } = require('googleapis');
let androidmanagement = google.androidmanagement('v1');
let googleAuth = new google.auth.GoogleAuth({
scopes: [
'https://www.googleapis.com/auth/androidmanagement',
'https://www.googleapis.com/auth/pubsub'
],
credentials: {
client_email: sails.config.custom.androidEnterpriseServiceAccountEmailAddress,// eslint-disable-line camelcase
private_key: sails.config.custom.androidEnterpriseServiceAccountPrivateKey,// eslint-disable-line camelcase
},
});
// Acquire the google auth client, and bind it to all future calls
let authClient = await googleAuth.getClient();
google.options({auth: authClient});
// Delete the android enterprise.
await androidmanagement.enterprises.delete({
name: `enterprises/${androidEnterpriseId}`,
});
let pubsub = google.pubsub('v1');
// Delete the enterprise's pubsub topic
await pubsub.projects.topics.delete({
topic: thisAndroidEnterprise.pubsubTopicName,
});
// Delete the topic's subscription, which should have the same name as the topic.
await pubsub.projects.subscriptions.delete({
subscription: thisAndroidEnterprise.pubsubSubscriptionName,
});
return;
}).intercept((err)=>{
return new Error(`When attempting to delete an android enterprise (${androidEnterpriseId}), an error occurred. Error: ${err}`);
});
// Delete the database record for this Android enterprise
await AndroidEnterprise.destroyOne({ id: thisAndroidEnterprise.id });
// All done. Send back an empty JSON object as expected by Android Management API.
return {};
}
};
@@ -0,0 +1,84 @@
module.exports = {
friendlyName: 'Modify android policies',
description: 'Modifies a policy of an Android enterprise',
inputs: {
androidEnterpriseId: {
type: 'string',
required: true,
},
policyId: {
type: 'string',
required: true,
},
},
exits: {
success: { description: 'The policy of an Android enterprise was successfully updated.' }
},
fn: async function ({ androidEnterpriseId, policyId}) {
// Extract fleetServerSecret from the Authorization header
let authHeader = this.req.get('authorization');
let fleetServerSecret;
if (authHeader && authHeader.startsWith('Bearer')) {
fleetServerSecret = authHeader.replace('Bearer', '').trim();
} else {
return this.res.unauthorized('Authorization header with Bearer token is required');
}
// Authenticate this request
let thisAndroidEnterprise = await AndroidEnterprise.findOne({
androidEnterpriseId: androidEnterpriseId
});
// Return a 404 response if no records are found.
if (!thisAndroidEnterprise) {
return this.res.notFound();
}
// Return an unauthorized response if the provided secret does not match.
if (thisAndroidEnterprise.fleetServerSecret !== fleetServerSecret) {
return this.res.unauthorized();
}
// Update the policy for this Android enterprise.
// Note: We're using sails.helpers.flow.build here to handle any errors that occurr using google's node library.
let modifyPoliciesResponse = await sails.helpers.flow.build(async () => {
let { google } = require('googleapis');
let androidmanagement = google.androidmanagement('v1');
let googleAuth = new google.auth.GoogleAuth({
scopes: ['https://www.googleapis.com/auth/androidmanagement'],
credentials: {
client_email: sails.config.custom.androidEnterpriseServiceAccountEmailAddress,// eslint-disable-line camelcase
private_key: sails.config.custom.androidEnterpriseServiceAccountPrivateKey,// eslint-disable-line camelcase
},
});
// Acquire the google auth client, and bind it to all future calls
let authClient = await googleAuth.getClient();
google.options({ auth: authClient });
// [?]: https://googleapis.dev/nodejs/googleapis/latest/androidmanagement/classes/Resource$Enterprises$Policies.html#patch
let patchPoliciesResponse = await androidmanagement.enterprises.policies.patch({
name: `enterprises/${androidEnterpriseId}/policies/${policyId}`,
requestBody: this.req.body,
});
return patchPoliciesResponse.data;
}).intercept((err) => {
return new Error(`When attempting to update a policy for an Android enterprise (${androidEnterpriseId}), an error occurred. Error: ${err}`);
});
// Return the modified policy back to the Fleet server.
return modifyPoliciesResponse;
}
};
+64
View File
@@ -0,0 +1,64 @@
/**
* AndroidEnterprise.js
*
* @description :: A model definition represents a database table/collection.
* @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models
*/
module.exports = {
attributes: {
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
fleetServerUrl: {
type: 'string',
description: 'The URL of the Fleet server that this Android enterprise exists on.',
unique: true,
required: true,
},
fleetLicenseKey: {
type: 'string',
description: 'The license key set on the Fleet server that this Android enterprise exists on.',
},
fleetServerSecret: {
type: 'string',
description: 'A secret randomly generated by the fleet website used to authenticate requests to the website for the other Android proxy endpoints after initial setup.',
required: true,
},
androidEnterpriseId: {
type: 'string',
description: 'Google\'s ID for this Android enterprise.',
unique: true,
required: true,
},
pubsubTopicName: {
type: 'string',
description: 'The pubsub topic name for this Android enterprise generated by fleetdm.com',
extendedDescription: 'This value is saved so we can delete the created pubsub topic if this Android enterprise is deleted.',
},
pubsubSubscriptionName: {
type: 'string',
description: 'The pubsub subscription name for the topic associated with this Android enterprise generated by fleetdm.com',
extendedDescription: 'This value is saved so we can delete the created pubsub subscription if this Android enterprise is deleted.',
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
// ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝
// ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
},
};
+1
View File
@@ -68,6 +68,7 @@
"Platform": false,
"AdCampaign": false,
"MicrosoftComplianceTenant": false,
"AndroidEnterprise": false
// ...and any other backend globals (e.g. `"Organization": false`)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
}
+7 -1
View File
@@ -424,11 +424,17 @@ module.exports.custom = {
// Deal registration form
// dealRegistrationContactEmailAddress: '…',
// Microsoft compliance proxy
// compliancePartnerClientId: '…',
// compliancePartnerClientSecret: '…',
// cloudCustomerCompliancePartnerSharedSecret: '…',
//…
// Android proxy
// androidEnterpriseProjectId: '…',
// androidEnterpriseServiceAccountEmailAddress: '…',
// androidEnterpriseServiceAccountPrivateKey: '…',
};
+1
View File
@@ -72,4 +72,5 @@ module.exports.policies = {
'account/update-start-cta-visibility': true,
'microsoft-proxy/receive-redirect-from-microsoft': true,
'view-configuration-builder': true,
'android-proxy/*': true,
};
+11
View File
@@ -952,6 +952,17 @@ module.exports.routes = {
'POST /api/v1/get-est-device-certificate': { action: 'get-est-device-certificate', csrf: false},
'POST /api/v1/webhooks/receive-from-clay': { action: 'webhooks/receive-from-clay', csrf: false},
// ╔═╗╔╗╔╔╦╗╦═╗╔═╗╦╔╦╗ ╔═╗╦═╗╔═╗═╗ ╦╦ ╦ ╔═╗╔╗╔╔╦╗╔═╗╔═╗╦╔╗╔╔╦╗╔═╗
// ╠═╣║║║ ║║╠╦╝║ ║║ ║║ ╠═╝╠╦╝║ ║╔╩╦╝╚╦╝ ║╣ ║║║ ║║╠═╝║ ║║║║║ ║ ╚═╗
// ╩ ╩╝╚╝═╩╝╩╚═╚═╝╩═╩╝ ╩ ╩╚═╚═╝╩ ╚═ ╩ ╚═╝╝╚╝═╩╝╩ ╚═╝╩╝╚╝ ╩ ╚═╝
'POST /api/android/v1/signupUrls': { action: 'android-proxy/create-android-signup-url', csrf: false},
'POST /api/android/v1/enterprises': { action: 'android-proxy/create-android-enterprise', csrf: false},
'POST /api/android/v1/enterprises/:androidEnterpriseId/enrollmentTokens': { action: 'android-proxy/create-android-enrollment-token', csrf: false},
'PATCH /api/android/v1/enterprises/:androidEnterpriseId/policies/:policyId': { action: 'android-proxy/modify-android-policies', csrf: false},
'DELETE /api/android/v1/enterprises/:androidEnterpriseId': { action: 'android-proxy/delete-one-android-enterprise', csrf: false},
// ╔═╗╔═╗╦ ╔═╗╔╗╔╔╦╗╔═╗╔═╗╦╔╗╔╔╦╗╔═╗
// ╠═╣╠═╝║ ║╣ ║║║ ║║╠═╝║ ║║║║║ ║ ╚═╗
// ╩ ╩╩ ╩ ╚═╝╝╚╝═╩╝╩ ╚═╝╩╝╚╝ ╩ ╚═╝
+1
View File
@@ -8,6 +8,7 @@
"@sailshq/connect-redis": "^6.1.3",
"@sailshq/lodash": "^3.10.7",
"@sailshq/socket.io-redis": "^6.1.2",
"googleapis": "148.0.0",
"jsforce": "1.11.1",
"jsonwebtoken": "9.0.2",
"jsrsasign": "11.1.0",