From 96565ce10eed73f410f87ef73e654aea880cf764 Mon Sep 17 00:00:00 2001 From: Dave Cavaletto Date: Tue, 10 Sep 2024 13:59:44 -0600 Subject: [PATCH 1/3] add private_offers_only config value --- api/Account.py | 18 ++++++ api/README.md | 7 +++ api/api.py | 127 ++++++++++++++++++++------------------ api/config.py | 3 + api/default_settings.toml | 3 +- 5 files changed, 97 insertions(+), 61 deletions(-) diff --git a/api/Account.py b/api/Account.py index acdfbf1..237987a 100644 --- a/api/Account.py +++ b/api/Account.py @@ -12,3 +12,21 @@ def handle_account(account_msg: dict, procurement_api: ProcurementApi): account = procurement_api.get_account(account_id) logger.debug("got account", account=account) + if account and settings.private_offers_only: + approval = None + for account_approval in account["approvals"]: + if account_approval["name"] == "signup": + approval = account_approval + break + logger.debug("found approval", approval=approval) + + if approval: + if approval["state"] == "PENDING": + logger.debug("approving account in procurementApi") + procurement_api.approve_account(account_id) + + elif approval["state"] == "APPROVED": + logger.info("account is approved") + else: + logger.debug("no approval found") + # The account has been deleted \ No newline at end of file diff --git a/api/README.md b/api/README.md index 5eb9772..efe9b99 100644 --- a/api/README.md +++ b/api/README.md @@ -7,6 +7,13 @@ From the api directory you can build and publish the app using the following com gcloud builds submit --tag /doit-easily:1.0 . +# Running the application locally + + cd api + docker build . -t doit-easily:local + gcloud auth login --cred-file='path/to/SA_key.json' + gcloud auth application-default login --impersonate-service-account=doit-easily@doit-test-davec-public.iam.gserviceaccount.com + docker run -e PORT=8080 -v ./default_settings.toml:/config/custom-settings.toml -e GOOGLE_APPLICATION_CREDENTIALS=/creds.json -v $GOOGLE_APPLICATION_CREDENTIALS:/creds.json -p 8080:8080 -e LOG_LEVEL=debug docker.io/library/doit-easily:local # Configuration diff --git a/api/api.py b/api/api.py index 3fc5d06..e9378b9 100644 --- a/api/api.py +++ b/api/api.py @@ -51,6 +51,7 @@ def entitlements(): return render_template("index.html", **page_context) except Exception as e: + logger.debug("an exception occurred loading index", exception=traceback.format_exc()) logger.error(e) return {"error": "Loading failed"}, 500 @@ -76,66 +77,72 @@ def show_account(account_id): logger.error(e) return {"error": "Loading failed"}, 500 -@app.route("/login", methods=["POST"]) -@app.route("/activate", methods=["POST"]) -def login(): - add_request_context_to_log(str(uuid.uuid4())) - encoded = request.form.get("x-gcp-marketplace-token") - logger.debug('encoded token', token=encoded) - if not encoded: - return "invalid header", 401 - header = jwt.get_unverified_header(encoded) - key_id = header["kid"] - # only to get the iss value - unverified_decoded = jwt.decode(encoded, options={"verify_signature": False}) - url = unverified_decoded["iss"] - - # Verify that the iss claim is https://www.googleapis.com/robot/v1/metadata/x509/cloud-commerce-partner@system.gserviceaccount.com. - if url != "https://www.googleapis.com/robot/v1/metadata/x509/cloud-commerce-partner@system.gserviceaccount.com": - logger.error('oh no! bad public key url') - return "", 401 - - # get the cert from the iss url, and resolve it to a public key - certs = requests.get(url=url).json() - cert = certs[key_id] - cert_obj = load_pem_x509_certificate(bytes(cert, 'utf-8')) - public_key = cert_obj.public_key() - - # Verify that the JWT signature is using the public key from Google. - try: - decoded = jwt.decode(encoded, public_key, algorithms=["RS256"], audience=settings.AUDIENCE, ) - except jwt.exceptions.InvalidAudienceError: - # Verify that the aud claim is the correct domain for your product. - logger.error('oh no! audience mismatch') - return "audience mismatch", 401 - except jwt.exceptions.ExpiredSignatureError: - # Verify that the JWT has not expired, by checking the exp claim. - logger.error('oh no! jwt expired') - return "JWT expired", 401 - - # Verify that sub is not empty. - if decoded["sub"] is None or decoded["sub"] == "": - logger.error('oh no! sub is empty') - return "sub empty", 401 - - # JWT validated, approve account - logger.debug('approving account', account=decoded["sub"]) - try: - response = procurement_api.approve_account(decoded["sub"]) - logger.info("procurement api approve complete", response={}) - if settings.auto_approve_entitlements: - # look for any pending entitlement creation requests and approve them - pending_creation_requests = procurement_api.list_entitlements(account_id=decoded["sub"]) - logger.debug("pending requests", pending_creation_requests=pending_creation_requests) - for pcr in pending_creation_requests["entitlements"]: - logger.debug("pending creation request", pcr=pcr) - entitlement_id = procurement_api.get_entitlement_id(pcr["name"]) - logger.info("approving entitlement", entitlement_id=entitlement_id) - procurement_api.approve_entitlement(entitlement_id) - return "Your account has been approved. You can close this window.", 200 - except Exception as e: - logger.error("an exception occurred approving accounts", exception=traceback.format_exc()) - return {"error": "failed to approve account"}, 500 +if not settings.private_offers_only: + @app.route("/login", methods=["POST"]) + @app.route("/activate", methods=["POST"]) + @app.route("/login", methods=["GET"]) + @app.route("/activate", methods=["GET"]) + def login(): + add_request_context_to_log(str(uuid.uuid4())) + if request.method == "GET": + return "Hello, World!", 200 + + encoded = request.form.get("x-gcp-marketplace-token") + logger.debug('encoded token', token=encoded) + if not encoded: + return "invalid header", 401 + header = jwt.get_unverified_header(encoded) + key_id = header["kid"] + # only to get the iss value + unverified_decoded = jwt.decode(encoded, options={"verify_signature": False}) + url = unverified_decoded["iss"] + + # Verify that the iss claim is https://www.googleapis.com/robot/v1/metadata/x509/cloud-commerce-partner@system.gserviceaccount.com. + if url != "https://www.googleapis.com/robot/v1/metadata/x509/cloud-commerce-partner@system.gserviceaccount.com": + logger.error('oh no! bad public key url') + return "", 401 + + # get the cert from the iss url, and resolve it to a public key + certs = requests.get(url=url).json() + cert = certs[key_id] + cert_obj = load_pem_x509_certificate(bytes(cert, 'utf-8')) + public_key = cert_obj.public_key() + + # Verify that the JWT signature is using the public key from Google. + try: + decoded = jwt.decode(encoded, public_key, algorithms=["RS256"], audience=settings.AUDIENCE, ) + except jwt.exceptions.InvalidAudienceError: + # Verify that the aud claim is the correct domain for your product. + logger.error('oh no! audience mismatch') + return "audience mismatch", 401 + except jwt.exceptions.ExpiredSignatureError: + # Verify that the JWT has not expired, by checking the exp claim. + logger.error('oh no! jwt expired') + return "JWT expired", 401 + + # Verify that sub is not empty. + if decoded["sub"] is None or decoded["sub"] == "": + logger.error('oh no! sub is empty') + return "sub empty", 401 + + # JWT validated, approve account + logger.debug('approving account', account=decoded["sub"]) + try: + response = procurement_api.approve_account(decoded["sub"]) + logger.info("procurement api approve complete", response={}) + if settings.auto_approve_entitlements: + # look for any pending entitlement creation requests and approve them + pending_creation_requests = procurement_api.list_entitlements(account_id=decoded["sub"]) + logger.debug("pending requests", pending_creation_requests=pending_creation_requests) + for pcr in pending_creation_requests["entitlements"]: + logger.debug("pending creation request", pcr=pcr) + entitlement_id = procurement_api.get_entitlement_id(pcr["name"]) + logger.info("approving entitlement", entitlement_id=entitlement_id) + procurement_api.approve_entitlement(entitlement_id) + return "Your account has been approved. You can close this window.", 200 + except Exception as e: + logger.error("an exception occurred approving accounts", exception=traceback.format_exc()) + return {"error": "failed to approve account"}, 500 # curl localhost:5000/v1/entitlement?state=CREATION_REQUESTED|ACTIVE|PLAN_CHANGE_REQUESTED|PLAN_CHANGED|PLAN_CHANGE_CANCELLED|CANCELLED|PENDING_CANCELLATION|CANCELLATION_REVERTED|DELETED diff --git a/api/config.py b/api/config.py index ef594fb..c1bdeee 100644 --- a/api/config.py +++ b/api/config.py @@ -16,11 +16,14 @@ Validator("marketplace_project", must_exist=True, is_type_of=str), # the domain that hosts your product, such as `example-pro.com` Validator("audience", must_exist=True, is_type_of=str), + Validator("private_offers_only", must_exist=True, is_type_of=bool), + # optional. If set, slack notifications will be sent. Validator("auto_approve_entitlements", must_exist=True, is_type_of=bool), # optional. If set, slack notifications will be sent. Validator("slack_webhook", eq=None) | Validator("slack_webhook", is_type_of=str), # optional. If set, google pubsub will be used. Validator("event_topic", eq=None) | Validator("event_topic", is_type_of=str), + ) settings.validators.validate_all() diff --git a/api/default_settings.toml b/api/default_settings.toml index db752a8..1852b8e 100644 --- a/api/default_settings.toml +++ b/api/default_settings.toml @@ -1,6 +1,7 @@ [default] -marketplace_project = '@none None' # This should be set to the name of the project you want to use, otherwise an error will be thrown. +marketplace_project = 'doit-test-dave-public' # This should be set to the name of the project you want to use, otherwise an error will be thrown. auto_approve_entitlements = false # This should be set to true if you want to auto-approve entitlements. +private_offers_only = false # This should be set to true if you want to auto-approve accounts and are only offering private offers. event_topic = '@none None' # Optional. The name of the topic to publish events to. slack_webhook = '@none None' # Optional. The webhook URL to send Slack messages to. audience = 'api.dns_zone_name' From adf3d1c7f356c287be0ef91a391a4374839e1e27 Mon Sep 17 00:00:00 2001 From: Dave Cavaletto Date: Tue, 10 Sep 2024 14:39:22 -0600 Subject: [PATCH 2/3] revert some project ids, bump requirement versions --- api/README.md | 2 +- api/default_settings.toml | 2 +- api/requirements.txt | 6 +++--- api/test/requirements.txt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/README.md b/api/README.md index efe9b99..da0e832 100644 --- a/api/README.md +++ b/api/README.md @@ -12,7 +12,7 @@ From the api directory you can build and publish the app using the following com cd api docker build . -t doit-easily:local gcloud auth login --cred-file='path/to/SA_key.json' - gcloud auth application-default login --impersonate-service-account=doit-easily@doit-test-davec-public.iam.gserviceaccount.com + gcloud auth application-default login --impersonate-service-account=doit-easily@.iam.gserviceaccount.com docker run -e PORT=8080 -v ./default_settings.toml:/config/custom-settings.toml -e GOOGLE_APPLICATION_CREDENTIALS=/creds.json -v $GOOGLE_APPLICATION_CREDENTIALS:/creds.json -p 8080:8080 -e LOG_LEVEL=debug docker.io/library/doit-easily:local # Configuration diff --git a/api/default_settings.toml b/api/default_settings.toml index 1852b8e..e744804 100644 --- a/api/default_settings.toml +++ b/api/default_settings.toml @@ -1,5 +1,5 @@ [default] -marketplace_project = 'doit-test-dave-public' # This should be set to the name of the project you want to use, otherwise an error will be thrown. +marketplace_project = '@none None' # This should be set to the name of the project you want to use, otherwise an error will be thrown. auto_approve_entitlements = false # This should be set to true if you want to auto-approve entitlements. private_offers_only = false # This should be set to true if you want to auto-approve accounts and are only offering private offers. event_topic = '@none None' # Optional. The name of the topic to publish events to. diff --git a/api/requirements.txt b/api/requirements.txt index 2b45698..d71ddca 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,12 +1,12 @@ -Flask==2.0.2 +Flask==2.3.2 gunicorn==20.1.0 google-cloud-pubsub==2.18.0 pg8000==1.24.0 structlog==21.5.0 -requests==2.31.0 +requests==2.32.0 google-api-python-client==2.94.0 ratelimit==2.2.1 backoff==2.1.2 dynaconf==3.1.9 pyjwt[crypto]==2.3.0 -Werkzeug==2.0.3 \ No newline at end of file +Werkzeug==3.0.3 \ No newline at end of file diff --git a/api/test/requirements.txt b/api/test/requirements.txt index e503079..7ef7602 100644 --- a/api/test/requirements.txt +++ b/api/test/requirements.txt @@ -1 +1 @@ -werkzeug==2.0.3 \ No newline at end of file +werkzeug==3.0.3 \ No newline at end of file From ba4c6c1f139821e85595a4a047113ab0053f31c6 Mon Sep 17 00:00:00 2001 From: Dave Cavaletto Date: Wed, 2 Oct 2024 09:37:38 -0600 Subject: [PATCH 3/3] improve messages --- api/Account.py | 2 +- api/api.py | 135 +++++++++++++++++++++++++------------------------ 2 files changed, 70 insertions(+), 67 deletions(-) diff --git a/api/Account.py b/api/Account.py index 237987a..92e312f 100644 --- a/api/Account.py +++ b/api/Account.py @@ -26,7 +26,7 @@ def handle_account(account_msg: dict, procurement_api: ProcurementApi): procurement_api.approve_account(account_id) elif approval["state"] == "APPROVED": - logger.info("account is approved") + logger.info("account is already approved, no action performed") else: logger.debug("no approval found") # The account has been deleted \ No newline at end of file diff --git a/api/api.py b/api/api.py index e9378b9..8525207 100644 --- a/api/api.py +++ b/api/api.py @@ -77,72 +77,75 @@ def show_account(account_id): logger.error(e) return {"error": "Loading failed"}, 500 -if not settings.private_offers_only: - @app.route("/login", methods=["POST"]) - @app.route("/activate", methods=["POST"]) - @app.route("/login", methods=["GET"]) - @app.route("/activate", methods=["GET"]) - def login(): - add_request_context_to_log(str(uuid.uuid4())) - if request.method == "GET": - return "Hello, World!", 200 - - encoded = request.form.get("x-gcp-marketplace-token") - logger.debug('encoded token', token=encoded) - if not encoded: - return "invalid header", 401 - header = jwt.get_unverified_header(encoded) - key_id = header["kid"] - # only to get the iss value - unverified_decoded = jwt.decode(encoded, options={"verify_signature": False}) - url = unverified_decoded["iss"] - - # Verify that the iss claim is https://www.googleapis.com/robot/v1/metadata/x509/cloud-commerce-partner@system.gserviceaccount.com. - if url != "https://www.googleapis.com/robot/v1/metadata/x509/cloud-commerce-partner@system.gserviceaccount.com": - logger.error('oh no! bad public key url') - return "", 401 - - # get the cert from the iss url, and resolve it to a public key - certs = requests.get(url=url).json() - cert = certs[key_id] - cert_obj = load_pem_x509_certificate(bytes(cert, 'utf-8')) - public_key = cert_obj.public_key() - - # Verify that the JWT signature is using the public key from Google. - try: - decoded = jwt.decode(encoded, public_key, algorithms=["RS256"], audience=settings.AUDIENCE, ) - except jwt.exceptions.InvalidAudienceError: - # Verify that the aud claim is the correct domain for your product. - logger.error('oh no! audience mismatch') - return "audience mismatch", 401 - except jwt.exceptions.ExpiredSignatureError: - # Verify that the JWT has not expired, by checking the exp claim. - logger.error('oh no! jwt expired') - return "JWT expired", 401 - - # Verify that sub is not empty. - if decoded["sub"] is None or decoded["sub"] == "": - logger.error('oh no! sub is empty') - return "sub empty", 401 - - # JWT validated, approve account - logger.debug('approving account', account=decoded["sub"]) - try: - response = procurement_api.approve_account(decoded["sub"]) - logger.info("procurement api approve complete", response={}) - if settings.auto_approve_entitlements: - # look for any pending entitlement creation requests and approve them - pending_creation_requests = procurement_api.list_entitlements(account_id=decoded["sub"]) - logger.debug("pending requests", pending_creation_requests=pending_creation_requests) - for pcr in pending_creation_requests["entitlements"]: - logger.debug("pending creation request", pcr=pcr) - entitlement_id = procurement_api.get_entitlement_id(pcr["name"]) - logger.info("approving entitlement", entitlement_id=entitlement_id) - procurement_api.approve_entitlement(entitlement_id) - return "Your account has been approved. You can close this window.", 200 - except Exception as e: - logger.error("an exception occurred approving accounts", exception=traceback.format_exc()) - return {"error": "failed to approve account"}, 500 + +@app.route("/login", methods=["POST"]) +@app.route("/activate", methods=["POST"]) +@app.route("/login", methods=["GET"]) +@app.route("/activate", methods=["GET"]) +def login(): + add_request_context_to_log(str(uuid.uuid4())) + if settings.private_offers_only: + return "This listing is only sold through custom pricing. Please contact the vendor.", 200 + + if request.method == "GET": + return "This integration is running.", 200 + + encoded = request.form.get("x-gcp-marketplace-token") + logger.debug('encoded token', token=encoded) + if not encoded: + return "invalid header", 401 + header = jwt.get_unverified_header(encoded) + key_id = header["kid"] + # only to get the iss value + unverified_decoded = jwt.decode(encoded, options={"verify_signature": False}) + url = unverified_decoded["iss"] + + # Verify that the iss claim is https://www.googleapis.com/robot/v1/metadata/x509/cloud-commerce-partner@system.gserviceaccount.com. + if url != "https://www.googleapis.com/robot/v1/metadata/x509/cloud-commerce-partner@system.gserviceaccount.com": + logger.error('oh no! bad public key url') + return "", 401 + + # get the cert from the iss url, and resolve it to a public key + certs = requests.get(url=url).json() + cert = certs[key_id] + cert_obj = load_pem_x509_certificate(bytes(cert, 'utf-8')) + public_key = cert_obj.public_key() + + # Verify that the JWT signature is using the public key from Google. + try: + decoded = jwt.decode(encoded, public_key, algorithms=["RS256"], audience=settings.AUDIENCE, ) + except jwt.exceptions.InvalidAudienceError: + # Verify that the aud claim is the correct domain for your product. + logger.error('oh no! audience mismatch') + return "audience mismatch", 401 + except jwt.exceptions.ExpiredSignatureError: + # Verify that the JWT has not expired, by checking the exp claim. + logger.error('oh no! jwt expired') + return "JWT expired", 401 + + # Verify that sub is not empty. + if decoded["sub"] is None or decoded["sub"] == "": + logger.error('oh no! sub is empty') + return "sub empty", 401 + + # JWT validated, approve account + logger.debug('approving account', account=decoded["sub"]) + try: + response = procurement_api.approve_account(decoded["sub"]) + logger.info("procurement api approve complete", response={}) + if settings.auto_approve_entitlements: + # look for any pending entitlement creation requests and approve them + pending_creation_requests = procurement_api.list_entitlements(account_id=decoded["sub"]) + logger.debug("pending requests", pending_creation_requests=pending_creation_requests) + for pcr in pending_creation_requests["entitlements"]: + logger.debug("pending creation request", pcr=pcr) + entitlement_id = procurement_api.get_entitlement_id(pcr["name"]) + logger.info("approving entitlement", entitlement_id=entitlement_id) + procurement_api.approve_entitlement(entitlement_id) + return "Your account has been approved. You can close this window.", 200 + except Exception as e: + logger.error("an exception occurred approving accounts", exception=traceback.format_exc()) + return {"error": "failed to approve account"}, 500 # curl localhost:5000/v1/entitlement?state=CREATION_REQUESTED|ACTIVE|PLAN_CHANGE_REQUESTED|PLAN_CHANGED|PLAN_CHANGE_CANCELLED|CANCELLED|PENDING_CANCELLATION|CANCELLATION_REVERTED|DELETED